From 84c8284805f1416bd518484b4e24dceaf48e3975 Mon Sep 17 00:00:00 2001 From: BattleJenkins Date: Thu, 9 Nov 2017 22:36:11 -0800 Subject: [PATCH 001/208] Added tb_magic.py - only basic input parsing --- evennia/contrib/turnbattle/tb_magic.py | 961 +++++++++++++++++++++++++ 1 file changed, 961 insertions(+) create mode 100644 evennia/contrib/turnbattle/tb_magic.py diff --git a/evennia/contrib/turnbattle/tb_magic.py b/evennia/contrib/turnbattle/tb_magic.py new file mode 100644 index 0000000000..cc639737c0 --- /dev/null +++ b/evennia/contrib/turnbattle/tb_magic.py @@ -0,0 +1,961 @@ +""" +Simple turn-based combat system + +Contrib - Tim Ashley Jenkins 2017 + +This is a framework for a simple turn-based combat system, similar +to those used in D&D-style tabletop role playing games. It allows +any character to start a fight in a room, at which point initiative +is rolled and a turn order is established. Each participant in combat +has a limited time to decide their action for that turn (30 seconds by +default), and combat progresses through the turn order, looping through +the participants until the fight ends. + +Only simple rolls for attacking are implemented here, but this system +is easily extensible and can be used as the foundation for implementing +the rules from your turn-based tabletop game of choice or making your +own battle system. + +To install and test, import this module's TBMagicCharacter object into +your game's character.py module: + + from evennia.contrib.turnbattle.tb_magic import TBMagicCharacter + +And change your game's character typeclass to inherit from TBMagicCharacter +instead of the default: + + class Character(TBMagicCharacter): + +Next, import this module into your default_cmdsets.py module: + + from evennia.contrib.turnbattle import tb_magic + +And add the battle command set to your default command set: + + # + # any commands you add below will overload the default ones. + # + self.add(tb_magic.BattleCmdSet()) + +This module is meant to be heavily expanded on, so you may want to copy it +to your game's 'world' folder and modify it there rather than importing it +in your game and using it as-is. +""" + +from random import randint +from evennia import DefaultCharacter, Command, default_cmds, DefaultScript +from evennia.commands.default.muxcommand import MuxCommand +from evennia.commands.default.help import CmdHelp + +""" +---------------------------------------------------------------------------- +OPTIONS +---------------------------------------------------------------------------- +""" + +TURN_TIMEOUT = 30 # Time before turns automatically end, in seconds +ACTIONS_PER_TURN = 1 # Number of actions allowed per turn + +""" +---------------------------------------------------------------------------- +COMBAT FUNCTIONS START HERE +---------------------------------------------------------------------------- +""" + +def roll_init(character): + """ + Rolls a number between 1-1000 to determine initiative. + + Args: + character (obj): The character to determine initiative for + + Returns: + initiative (int): The character's place in initiative - higher + numbers go first. + + Notes: + By default, does not reference the character and simply returns + a random integer from 1 to 1000. + + Since the character is passed to this function, you can easily reference + a character's stats to determine an initiative roll - for example, if your + character has a 'dexterity' attribute, you can use it to give that character + an advantage in turn order, like so: + + return (randint(1,20)) + character.db.dexterity + + This way, characters with a higher dexterity will go first more often. + """ + return randint(1, 1000) + + +def get_attack(attacker, defender): + """ + Returns a value for an attack roll. + + Args: + attacker (obj): Character doing the attacking + defender (obj): Character being attacked + + Returns: + attack_value (int): Attack roll value, compared against a defense value + to determine whether an attack hits or misses. + + Notes: + By default, returns a random integer from 1 to 100 without using any + properties from either the attacker or defender. + + This can easily be expanded to return a value based on characters stats, + equipment, and abilities. This is why the attacker and defender are passed + to this function, even though nothing from either one are used in this example. + """ + # For this example, just return a random integer up to 100. + attack_value = randint(1, 100) + return attack_value + + +def get_defense(attacker, defender): + """ + Returns a value for defense, which an attack roll must equal or exceed in order + for an attack to hit. + + Args: + attacker (obj): Character doing the attacking + defender (obj): Character being attacked + + Returns: + defense_value (int): Defense value, compared against an attack roll + to determine whether an attack hits or misses. + + Notes: + By default, returns 50, not taking any properties of the defender or + attacker into account. + + As above, this can be expanded upon based on character stats and equipment. + """ + # For this example, just return 50, for about a 50/50 chance of hit. + defense_value = 50 + return defense_value + + +def get_damage(attacker, defender): + """ + Returns a value for damage to be deducted from the defender's HP after abilities + successful hit. + + Args: + attacker (obj): Character doing the attacking + defender (obj): Character being damaged + + Returns: + damage_value (int): Damage value, which is to be deducted from the defending + character's HP. + + Notes: + By default, returns a random integer from 15 to 25 without using any + properties from either the attacker or defender. + + Again, this can be expanded upon. + """ + # For this example, just generate a number between 15 and 25. + damage_value = randint(15, 25) + return damage_value + + +def apply_damage(defender, damage): + """ + Applies damage to a target, reducing their HP by the damage amount to a + minimum of 0. + + Args: + defender (obj): Character taking damage + damage (int): Amount of damage being taken + """ + defender.db.hp -= damage # Reduce defender's HP by the damage dealt. + # If this reduces it to 0 or less, set HP to 0. + if defender.db.hp <= 0: + defender.db.hp = 0 + +def at_defeat(defeated): + """ + Announces the defeat of a fighter in combat. + + Args: + defeated (obj): Fighter that's been defeated. + + Notes: + All this does is announce a defeat message by default, but if you + want anything else to happen to defeated fighters (like putting them + into a dying state or something similar) then this is the place to + do it. + """ + defeated.location.msg_contents("%s has been defeated!" % defeated) + +def resolve_attack(attacker, defender, attack_value=None, defense_value=None): + """ + Resolves an attack and outputs the result. + + Args: + attacker (obj): Character doing the attacking + defender (obj): Character being attacked + + Notes: + Even though the attack and defense values are calculated + extremely simply, they are separated out into their own functions + so that they are easier to expand upon. + """ + # Get an attack roll from the attacker. + if not attack_value: + attack_value = get_attack(attacker, defender) + # Get a defense value from the defender. + if not defense_value: + defense_value = get_defense(attacker, defender) + # If the attack value is lower than the defense value, miss. Otherwise, hit. + if attack_value < defense_value: + attacker.location.msg_contents("%s's attack misses %s!" % (attacker, defender)) + else: + damage_value = get_damage(attacker, defender) # Calculate damage value. + # Announce damage dealt and apply damage. + attacker.location.msg_contents("%s hits %s for %i damage!" % (attacker, defender, damage_value)) + apply_damage(defender, damage_value) + # If defender HP is reduced to 0 or less, call at_defeat. + if defender.db.hp <= 0: + at_defeat(defender) + +def combat_cleanup(character): + """ + Cleans up all the temporary combat-related attributes on a character. + + Args: + character (obj): Character to have their combat attributes removed + + Notes: + Any attribute whose key begins with 'combat_' is temporary and no + longer needed once a fight ends. + """ + for attr in character.attributes.all(): + if attr.key[:7] == "combat_": # If the attribute name starts with 'combat_'... + character.attributes.remove(key=attr.key) # ...then delete it! + + +def is_in_combat(character): + """ + Returns true if the given character is in combat. + + Args: + character (obj): Character to determine if is in combat or not + + Returns: + (bool): True if in combat or False if not in combat + """ + return bool(character.db.combat_turnhandler) + + +def is_turn(character): + """ + Returns true if it's currently the given character's turn in combat. + + Args: + character (obj): Character to determine if it is their turn or not + + Returns: + (bool): True if it is their turn or False otherwise + """ + turnhandler = character.db.combat_turnhandler + currentchar = turnhandler.db.fighters[turnhandler.db.turn] + return bool(character == currentchar) + + +def spend_action(character, actions, action_name=None): + """ + Spends a character's available combat actions and checks for end of turn. + + Args: + character (obj): Character spending the action + actions (int) or 'all': Number of actions to spend, or 'all' to spend all actions + + Kwargs: + action_name (str or None): If a string is given, sets character's last action in + combat to provided string + """ + if not is_in_combat(character): + return + if action_name: + character.db.combat_lastaction = action_name + if actions == 'all': # If spending all actions + character.db.combat_actionsleft = 0 # Set actions to 0 + else: + character.db.combat_actionsleft -= actions # Use up actions. + if character.db.combat_actionsleft < 0: + character.db.combat_actionsleft = 0 # Can't have fewer than 0 actions + character.db.combat_turnhandler.turn_end_check(character) # Signal potential end of turn. + + +""" +---------------------------------------------------------------------------- +CHARACTER TYPECLASS +---------------------------------------------------------------------------- +""" + + +class TBMagicCharacter(DefaultCharacter): + """ + A character able to participate in turn-based combat. Has attributes for current + and maximum HP, and access to combat commands. + """ + + def at_object_creation(self): + """ + Called once, when this object is first created. This is the + normal hook to overload for most object types. + """ + self.db.max_hp = 100 # Set maximum HP to 100 + self.db.hp = self.db.max_hp # Set current HP to maximum + self.db.spells_known = [] # Set empty spells known list + """ + Adds attributes for a character's current and maximum HP. + We're just going to set this value at '100' by default. + + You may want to expand this to include various 'stats' that + can be changed at creation and factor into combat calculations. + """ + + def at_before_move(self, destination): + """ + Called just before starting to move this object to + destination. + + Args: + destination (Object): The object we are moving to + + Returns: + shouldmove (bool): If we should move or not. + + Notes: + If this method returns False/None, the move is cancelled + before it is even started. + + """ + # Keep the character from moving if at 0 HP or in combat. + if is_in_combat(self): + self.msg("You can't exit a room while in combat!") + return False # Returning false keeps the character from moving. + if self.db.HP <= 0: + self.msg("You can't move, you've been defeated!") + return False + return True + +""" +---------------------------------------------------------------------------- +SCRIPTS START HERE +---------------------------------------------------------------------------- +""" + + +class TBMagicTurnHandler(DefaultScript): + """ + This is the script that handles the progression of combat through turns. + On creation (when a fight is started) it adds all combat-ready characters + to its roster and then sorts them into a turn order. There can only be one + fight going on in a single room at a time, so the script is assigned to a + room as its object. + + Fights persist until only one participant is left with any HP or all + remaining participants choose to end the combat with the 'disengage' command. + """ + + def at_script_creation(self): + """ + Called once, when the script is created. + """ + self.key = "Combat Turn Handler" + self.interval = 5 # Once every 5 seconds + self.persistent = True + self.db.fighters = [] + + # Add all fighters in the room with at least 1 HP to the combat." + for thing in self.obj.contents: + if thing.db.hp: + self.db.fighters.append(thing) + + # Initialize each fighter for combat + for fighter in self.db.fighters: + self.initialize_for_combat(fighter) + + # Add a reference to this script to the room + self.obj.db.combat_turnhandler = self + + # Roll initiative and sort the list of fighters depending on who rolls highest to determine turn order. + # The initiative roll is determined by the roll_init function and can be customized easily. + ordered_by_roll = sorted(self.db.fighters, key=roll_init, reverse=True) + self.db.fighters = ordered_by_roll + + # Announce the turn order. + self.obj.msg_contents("Turn order is: %s " % ", ".join(obj.key for obj in self.db.fighters)) + + # Start first fighter's turn. + self.start_turn(self.db.fighters[0]) + + # Set up the current turn and turn timeout delay. + self.db.turn = 0 + self.db.timer = TURN_TIMEOUT # Set timer to turn timeout specified in options + + def at_stop(self): + """ + Called at script termination. + """ + for fighter in self.db.fighters: + combat_cleanup(fighter) # Clean up the combat attributes for every fighter. + self.obj.db.combat_turnhandler = None # Remove reference to turn handler in location + + def at_repeat(self): + """ + Called once every self.interval seconds. + """ + currentchar = self.db.fighters[self.db.turn] # Note the current character in the turn order. + self.db.timer -= self.interval # Count down the timer. + + if self.db.timer <= 0: + # Force current character to disengage if timer runs out. + self.obj.msg_contents("%s's turn timed out!" % currentchar) + spend_action(currentchar, 'all', action_name="disengage") # Spend all remaining actions. + return + elif self.db.timer <= 10 and not self.db.timeout_warning_given: # 10 seconds left + # Warn the current character if they're about to time out. + currentchar.msg("WARNING: About to time out!") + self.db.timeout_warning_given = True + + def initialize_for_combat(self, character): + """ + Prepares a character for combat when starting or entering a fight. + + Args: + character (obj): Character to initialize for combat. + """ + combat_cleanup(character) # Clean up leftover combat attributes beforehand, just in case. + character.db.combat_actionsleft = 0 # Actions remaining - start of turn adds to this, turn ends when it reaches 0 + character.db.combat_turnhandler = self # Add a reference to this turn handler script to the character + character.db.combat_lastaction = "null" # Track last action taken in combat + + def start_turn(self, character): + """ + Readies a character for the start of their turn by replenishing their + available actions and notifying them that their turn has come up. + + Args: + character (obj): Character to be readied. + + Notes: + Here, you only get one action per turn, but you might want to allow more than + one per turn, or even grant a number of actions based on a character's + attributes. You can even add multiple different kinds of actions, I.E. actions + separated for movement, by adding "character.db.combat_movesleft = 3" or + something similar. + """ + character.db.combat_actionsleft = ACTIONS_PER_TURN # Replenish actions + # Prompt the character for their turn and give some information. + character.msg("|wIt's your turn! You have %i HP remaining.|n" % character.db.hp) + + def next_turn(self): + """ + Advances to the next character in the turn order. + """ + + # Check to see if every character disengaged as their last action. If so, end combat. + disengage_check = True + for fighter in self.db.fighters: + if fighter.db.combat_lastaction != "disengage": # If a character has done anything but disengage + disengage_check = False + if disengage_check: # All characters have disengaged + self.obj.msg_contents("All fighters have disengaged! Combat is over!") + self.stop() # Stop this script and end combat. + return + + # Check to see if only one character is left standing. If so, end combat. + defeated_characters = 0 + for fighter in self.db.fighters: + if fighter.db.HP == 0: + defeated_characters += 1 # Add 1 for every fighter with 0 HP left (defeated) + if defeated_characters == (len(self.db.fighters) - 1): # If only one character isn't defeated + for fighter in self.db.fighters: + if fighter.db.HP != 0: + LastStanding = fighter # Pick the one fighter left with HP remaining + self.obj.msg_contents("Only %s remains! Combat is over!" % LastStanding) + self.stop() # Stop this script and end combat. + return + + # Cycle to the next turn. + currentchar = self.db.fighters[self.db.turn] + self.db.turn += 1 # Go to the next in the turn order. + if self.db.turn > len(self.db.fighters) - 1: + self.db.turn = 0 # Go back to the first in the turn order once you reach the end. + newchar = self.db.fighters[self.db.turn] # Note the new character + self.db.timer = TURN_TIMEOUT + self.time_until_next_repeat() # Reset the timer. + self.db.timeout_warning_given = False # Reset the timeout warning. + self.obj.msg_contents("%s's turn ends - %s's turn begins!" % (currentchar, newchar)) + self.start_turn(newchar) # Start the new character's turn. + + def turn_end_check(self, character): + """ + Tests to see if a character's turn is over, and cycles to the next turn if it is. + + Args: + character (obj): Character to test for end of turn + """ + if not character.db.combat_actionsleft: # Character has no actions remaining + self.next_turn() + return + + def join_fight(self, character): + """ + Adds a new character to a fight already in progress. + + Args: + character (obj): Character to be added to the fight. + """ + # Inserts the fighter to the turn order, right behind whoever's turn it currently is. + self.db.fighters.insert(self.db.turn, character) + # Tick the turn counter forward one to compensate. + self.db.turn += 1 + # Initialize the character like you do at the start. + self.initialize_for_combat(character) + + +""" +---------------------------------------------------------------------------- +COMMANDS START HERE +---------------------------------------------------------------------------- +""" + + +class CmdFight(Command): + """ + Starts a fight with everyone in the same room as you. + + Usage: + fight + + When you start a fight, everyone in the room who is able to + fight is added to combat, and a turn order is randomly rolled. + When it's your turn, you can attack other characters. + """ + key = "fight" + help_category = "combat" + + def func(self): + """ + This performs the actual command. + """ + here = self.caller.location + fighters = [] + + if not self.caller.db.hp: # If you don't have any hp + self.caller.msg("You can't start a fight if you've been defeated!") + return + if is_in_combat(self.caller): # Already in a fight + self.caller.msg("You're already in a fight!") + return + for thing in here.contents: # Test everything in the room to add it to the fight. + if thing.db.HP: # If the object has HP... + fighters.append(thing) # ...then add it to the fight. + if len(fighters) <= 1: # If you're the only able fighter in the room + self.caller.msg("There's nobody here to fight!") + return + if here.db.combat_turnhandler: # If there's already a fight going on... + here.msg_contents("%s joins the fight!" % self.caller) + here.db.combat_turnhandler.join_fight(self.caller) # Join the fight! + return + here.msg_contents("%s starts a fight!" % self.caller) + # Add a turn handler script to the room, which starts combat. + here.scripts.add("contrib.turnbattle.tb_magic.TBMagicTurnHandler") + # Remember you'll have to change the path to the script if you copy this code to your own modules! + + +class CmdAttack(Command): + """ + Attacks another character. + + Usage: + attack + + When in a fight, you may attack another character. The attack has + a chance to hit, and if successful, will deal damage. + """ + + key = "attack" + help_category = "combat" + + def func(self): + "This performs the actual command." + "Set the attacker to the caller and the defender to the target." + + if not is_in_combat(self.caller): # If not in combat, can't attack. + self.caller.msg("You can only do that in combat. (see: help fight)") + return + + if not is_turn(self.caller): # If it's not your turn, can't attack. + self.caller.msg("You can only do that on your turn.") + return + + if not self.caller.db.hp: # Can't attack if you have no HP. + self.caller.msg("You can't attack, you've been defeated.") + return + + attacker = self.caller + defender = self.caller.search(self.args) + + if not defender: # No valid target given. + return + + if not defender.db.hp: # Target object has no HP left or to begin with + self.caller.msg("You can't fight that!") + return + + if attacker == defender: # Target and attacker are the same + self.caller.msg("You can't attack yourself!") + return + + "If everything checks out, call the attack resolving function." + resolve_attack(attacker, defender) + spend_action(self.caller, 1, action_name="attack") # Use up one action. + + +class CmdPass(Command): + """ + Passes on your turn. + + Usage: + pass + + When in a fight, you can use this command to end your turn early, even + if there are still any actions you can take. + """ + + key = "pass" + aliases = ["wait", "hold"] + help_category = "combat" + + def func(self): + """ + This performs the actual command. + """ + if not is_in_combat(self.caller): # Can only pass a turn in combat. + self.caller.msg("You can only do that in combat. (see: help fight)") + return + + if not is_turn(self.caller): # Can only pass if it's your turn. + self.caller.msg("You can only do that on your turn.") + return + + self.caller.location.msg_contents("%s takes no further action, passing the turn." % self.caller) + spend_action(self.caller, 'all', action_name="pass") # Spend all remaining actions. + + +class CmdDisengage(Command): + """ + Passes your turn and attempts to end combat. + + Usage: + disengage + + Ends your turn early and signals that you're trying to end + the fight. If all participants in a fight disengage, the + fight ends. + """ + + key = "disengage" + aliases = ["spare"] + help_category = "combat" + + def func(self): + """ + This performs the actual command. + """ + if not is_in_combat(self.caller): # If you're not in combat + self.caller.msg("You can only do that in combat. (see: help fight)") + return + + if not is_turn(self.caller): # If it's not your turn + self.caller.msg("You can only do that on your turn.") + return + + self.caller.location.msg_contents("%s disengages, ready to stop fighting." % self.caller) + spend_action(self.caller, 'all', action_name="disengage") # Spend all remaining actions. + """ + The action_name kwarg sets the character's last action to "disengage", which is checked by + the turn handler script to see if all fighters have disengaged. + """ + +class CmdLearnSpell(Command): + """ + Learn a magic spell + """ + + key = "learnspell" + help_category = "magic" + + def func(self): + """ + This performs the actual command. + """ + spell_list = sorted(SPELLS.keys()) + args = self.args.lower() + args = args.strip(" ") + caller = self.caller + spell_to_learn = [] + + if not args or len(args) < 3: + caller.msg("Usage: learnspell ") + return + + for spell in spell_list: # Match inputs to spells + if args in spell.lower(): + spell_to_learn.append(spell) + + if spell_to_learn == []: # No spells matched + caller.msg("There is no spell with that name.") + return + if len(spell_to_learn) > 1: # More than one match + matched_spells = ', '.join(spell_to_learn) + caller.msg("Which spell do you mean: %s?" % matched_spells) + return + + if len(spell_to_learn) == 1: # If one match, extract the string + spell_to_learn = spell_to_learn[0] + + if spell_to_learn not in self.caller.db.spells_known: + caller.db.spells_known.append(spell_to_learn) + caller.msg("You learn the spell '%s'!" % spell_to_learn) + return + if spell_to_learn in self.caller.db.spells_known: + caller.msg("You already know the spell '%s'!" % spell_to_learn) + +class CmdCast(MuxCommand): + """ + Cast a magic spell! + """ + + key = "cast" + help_category = "magic" + + def func(self): + """ + This performs the actual command. + """ + caller = self.caller + + syntax_err = "Usage: cast [= ]" + if not self.lhs or len(self.lhs) < 3: # No spell name given + self.caller.msg(syntax_err) + return + + spellname = self.lhs.lower() + spell_to_cast = [] + spell_targets = [] + + if not self.rhs: + spell_targets = [] + elif self.rhs.lower() in ['me', 'self', 'myself']: + spell_targets = [caller] + elif len(self.rhs) > 2: + spell_targets = self.rhslist + + for spell in caller.db.spells_known: # Match inputs to spells + if self.lhs in spell.lower(): + spell_to_cast.append(spell) + + if spell_to_cast == []: # No spells matched + caller.msg("You don't know a spell of that name.") + return + if len(spell_to_cast) > 1: # More than one match + matched_spells = ', '.join(spell_to_cast) + caller.msg("Which spell do you mean: %s?" % matched_spells) + return + + if len(spell_to_cast) == 1: # If one match, extract the string + spell_to_cast = spell_to_cast[0] + + if spell_to_cast not in SPELLS: # Spell isn't defined + caller.msg("ERROR: Spell %s is undefined" % spell_to_cast) + return + + # Time to extract some info from the chosen spell! + spelldata = SPELLS[spell_to_cast] + + # Add in some default data if optional parameters aren't specified + if "combat_spell" not in spelldata: + spelldata.update({"combat_spell":True}) + if "noncombat_spell" not in spelldata: + spelldata.update({"noncombat_spell":True}) + if "max_targets" not in spelldata: + spelldata.update({"max_targets":1}) + + # If spell takes no targets, give error message and return + if len(spell_targets) > 0 and spelldata["target"] == "none": + caller.msg("The spell '%s' isn't cast on a target.") + return + + # If no target is given and spell requires a target, give error message + if spelldata["target"] not in ["self", "none"]: + if len(spell_targets) == 0: + caller.msg("The spell %s requires a target." % spell_to_cast) + return + + # If more targets given than maximum, give error message + if len(spell_targets) > spelldata["max_targets"]: + targplural = "target" + if spelldata["max_targets"] > 1: + targplural = "targets" + caller.msg("The spell %s can only be cast on %i %s." % (spell_to_cast, spelldata["max_targets"], targplural)) + return + + # Set up our candidates for targets + target_candidates = [] + + if spelldata["target"] in ["any", "other"]: + target_candidates = caller.location.contents + caller.contents + + if spelldata["target"] == "anyobj": + prefilter_candidates = caller.location.contents + caller.contents + for thing in prefilter_candidates: + if not thing.attributes.has("max_hp"): # Has no max HP, isn't a fighter + target_candidates.append(thing) + + if spelldata["target"] in ["anychar", "otherchar"]: + prefilter_candidates = caller.location.contents + for thing in prefilter_candidates: + if thing.attributes.has("max_hp"): # Has max HP, is a fighter + target_candidates.append(thing) + + # Now, match each entry in spell_targets to an object + matched_targets = [] + for target in spell_targets: + match = caller.search(target, candidates=target_candidates) + matched_targets.append(match) + spell_targets = matched_targets + + # If no target is given and the spell's target is 'self', set target to self + if len(spell_targets) == 0 and spelldata["target"] == "self": + spell_targets = [caller] + + # Give error message if trying to cast an "other" target spell on yourself + if spelldata["target"] in ["other", "otherchar"]: + if caller in spell_targets: + caller.msg("You can't cast %s on yourself." % spell_to_cast) + return + + # Return if "None" in target list, indicating failed match + if None in spell_targets: + return + + # Give error message if repeats in target list + if len(spell_targets) != len(set(spell_targets)): + caller.msg("You can't specify the same target more than once!") + return + + caller.msg("You cast %s! Fwooosh!" % spell_to_cast) + + +class CmdRest(Command): + """ + Recovers damage. + + Usage: + rest + + Resting recovers your HP to its maximum, but you can only + rest if you're not in a fight. + """ + + key = "rest" + help_category = "combat" + + def func(self): + "This performs the actual command." + + if is_in_combat(self.caller): # If you're in combat + self.caller.msg("You can't rest while you're in combat.") + return + + self.caller.db.hp = self.caller.db.max_hp # Set current HP to maximum + self.caller.location.msg_contents("%s rests to recover HP." % self.caller) + """ + You'll probably want to replace this with your own system for recovering HP. + """ + + +class CmdCombatHelp(CmdHelp): + """ + View help or a list of topics + + Usage: + help + help list + help all + + This will search for help on commands and other + topics related to the game. + """ + # Just like the default help command, but will give quick + # tips on combat when used in a fight with no arguments. + + def func(self): + if is_in_combat(self.caller) and not self.args: # In combat and entered 'help' alone + self.caller.msg("Available combat commands:|/" + + "|wAttack:|n Attack a target, attempting to deal damage.|/" + + "|wPass:|n Pass your turn without further action.|/" + + "|wDisengage:|n End your turn and attempt to end combat.|/") + else: + super(CmdCombatHelp, self).func() # Call the default help command + + +class BattleCmdSet(default_cmds.CharacterCmdSet): + """ + This command set includes all the commmands used in the battle system. + """ + key = "DefaultCharacter" + + def at_cmdset_creation(self): + """ + Populates the cmdset + """ + self.add(CmdFight()) + self.add(CmdAttack()) + self.add(CmdRest()) + self.add(CmdPass()) + self.add(CmdDisengage()) + self.add(CmdCombatHelp()) + self.add(CmdLearnSpell()) + self.add(CmdCast()) + +""" +Required values for spells: + + cost (int): MP cost of casting the spell + target (str): Valid targets for the spell. Can be any of: + "none" - No target needed + "self" - Self only + "any" - Any object + "anyobj" - Any object that isn't a character + "anychar" - Any character + "other" - Any object excluding the caster + "otherchar" - Any character excluding the caster + spellfunc (callable): Function that performs the action of the spell. + Must take the following arguments: caster (obj), targets (list), + and cost(int). + +Optional values for spells: + + combat_spell (bool): If the spell can be cast in combat. True by default. + noncombat_spell (bool): If the spell can be cast out of combat. True by default. + max_targets (int): Maximum number of objects that can be targeted by the spell. + 1 by default - unused if target is "none" or "self" + +Any other values specified besides the above will be passed as kwargs to the spellfunc. + +""" + +SPELLS = { +"magic missile":{"spellfunc":None, "target":"otherchar", "cost":3, "noncombat_spell":False, "max_targets":3}, +"cure wounds":{"spellfunc":None, "target":"anychar", "cost":5}, +} \ No newline at end of file From d87be0c5293da6b6504a9865058a6b1e02e52734 Mon Sep 17 00:00:00 2001 From: FlutterSprite Date: Sun, 12 Nov 2017 18:23:22 -0800 Subject: [PATCH 002/208] Added functional 'cure wounds' spell Also added more spell verification in the 'cast' command, accounting for spell's MP cost and whether it can be used in combat --- evennia/contrib/turnbattle/tb_magic.py | 83 ++++++++++++++++++++++---- 1 file changed, 72 insertions(+), 11 deletions(-) diff --git a/evennia/contrib/turnbattle/tb_magic.py b/evennia/contrib/turnbattle/tb_magic.py index cc639737c0..7b59393e23 100644 --- a/evennia/contrib/turnbattle/tb_magic.py +++ b/evennia/contrib/turnbattle/tb_magic.py @@ -312,6 +312,8 @@ class TBMagicCharacter(DefaultCharacter): self.db.max_hp = 100 # Set maximum HP to 100 self.db.hp = self.db.max_hp # Set current HP to maximum self.db.spells_known = [] # Set empty spells known list + self.db.max_mp = 20 # Set maximum MP to 20 + self.db.mp = self.db.max_mp # Set current MP to maximum """ Adds attributes for a character's current and maximum HP. We're just going to set this value at '100' by default. @@ -733,6 +735,13 @@ class CmdLearnSpell(Command): class CmdCast(MuxCommand): """ Cast a magic spell! + + Notes: This is a quite long command, since it has to cope with all + the different circumstances in which you may or may not be able + to cast a spell. None of the spell's effects are handled by the + command - all the command does is verify that the player's input + is valid for the spell being cast and then call the spell's + function. """ key = "cast" @@ -789,8 +798,29 @@ class CmdCast(MuxCommand): spelldata.update({"noncombat_spell":True}) if "max_targets" not in spelldata: spelldata.update({"max_targets":1}) + + # Store any superfluous options as kwargs to pass to the spell function + kwargs = {} + spelldata_opts = ["spellfunc", "target", "cost", "combat_spell", "noncombat_spell", "max_targets"] + for key in spelldata: + if key not in spelldata_opts: + kwargs.update({key:spelldata[key]}) + + # If caster doesn't have enough MP to cover the spell's cost, give error and return + if spelldata["cost"] > caller.db.mp: + caller.msg("You don't have enough MP to cast '%s'." % spell_to_cast) + + # If in combat and the spell isn't a combat spell, give error message and return + if spelldata["combat_spell"] == False and is_in_combat(caller): + caller.msg("You can't use the spell '%s' in combat." % spell_to_cast) + return + + # If not in combat and the spell isn't a non-combat spell, error ms and return. + if spelldata["noncombat_spell"] == False and is_in_combat(caller) == False: + caller.msg("You can't use the spell '%s' outside of combat." % spell_to_cast) + return - # If spell takes no targets, give error message and return + # If spell takes no targets and one is given, give error message and return if len(spell_targets) > 0 and spelldata["target"] == "none": caller.msg("The spell '%s' isn't cast on a target.") return @@ -798,7 +828,7 @@ class CmdCast(MuxCommand): # If no target is given and spell requires a target, give error message if spelldata["target"] not in ["self", "none"]: if len(spell_targets) == 0: - caller.msg("The spell %s requires a target." % spell_to_cast) + caller.msg("The spell '%s' requires a target." % spell_to_cast) return # If more targets given than maximum, give error message @@ -806,28 +836,32 @@ class CmdCast(MuxCommand): targplural = "target" if spelldata["max_targets"] > 1: targplural = "targets" - caller.msg("The spell %s can only be cast on %i %s." % (spell_to_cast, spelldata["max_targets"], targplural)) + caller.msg("The spell '%s' can only be cast on %i %s." % (spell_to_cast, spelldata["max_targets"], targplural)) return # Set up our candidates for targets target_candidates = [] + # If spell targets 'any' or 'other', any object in caster's inventory or location + # can be targeted by the spell. if spelldata["target"] in ["any", "other"]: target_candidates = caller.location.contents + caller.contents + # If spell targets 'anyobj', only non-character objects can be targeted. if spelldata["target"] == "anyobj": prefilter_candidates = caller.location.contents + caller.contents for thing in prefilter_candidates: if not thing.attributes.has("max_hp"): # Has no max HP, isn't a fighter target_candidates.append(thing) + # If spell targets 'anychar' or 'otherchar', only characters can be targeted. if spelldata["target"] in ["anychar", "otherchar"]: prefilter_candidates = caller.location.contents for thing in prefilter_candidates: if thing.attributes.has("max_hp"): # Has max HP, is a fighter target_candidates.append(thing) - # Now, match each entry in spell_targets to an object + # Now, match each entry in spell_targets to an object in the search candidates matched_targets = [] for target in spell_targets: match = caller.search(target, candidates=target_candidates) @@ -841,11 +875,12 @@ class CmdCast(MuxCommand): # Give error message if trying to cast an "other" target spell on yourself if spelldata["target"] in ["other", "otherchar"]: if caller in spell_targets: - caller.msg("You can't cast %s on yourself." % spell_to_cast) + caller.msg("You can't cast '%s' on yourself." % spell_to_cast) return # Return if "None" in target list, indicating failed match if None in spell_targets: + # No need to give an error message, as 'search' gives one by default. return # Give error message if repeats in target list @@ -853,7 +888,8 @@ class CmdCast(MuxCommand): caller.msg("You can't specify the same target more than once!") return - caller.msg("You cast %s! Fwooosh!" % spell_to_cast) + # Finally, we can cast the spell itself + spelldata["spellfunc"](caller, spell_to_cast, spell_targets, spelldata["cost"], **kwargs) class CmdRest(Command): @@ -927,7 +963,31 @@ class BattleCmdSet(default_cmds.CharacterCmdSet): self.add(CmdCombatHelp()) self.add(CmdLearnSpell()) self.add(CmdCast()) - + +""" +SPELL FUNCTIONS START HERE +""" + +def spell_cure_wounds(caster, spell_name, targets, cost, **kwargs): + """ + Spell that restores HP to a target. + """ + spell_msg = "%s casts %s!" % (caster, spell_name) + + for character in targets: + to_heal = randint(20, 40) # Restore 20 to 40 hp + if character.db.hp + to_heal > character.db.max_hp: + to_heal = character.db.max_hp - character.db.hp # Cap healing to max HP + character.db.hp += to_heal + spell_msg += " %s regains %i HP!" % (character, to_heal) + + caster.db.mp -= cost # Deduct MP cost + + caster.location.msg_contents(spell_msg) # Message the room with spell results + + if is_in_combat(caster): # Spend action if in combat + spend_action(caster, 1, action_name="cast") + """ Required values for spells: @@ -941,8 +1001,8 @@ Required values for spells: "other" - Any object excluding the caster "otherchar" - Any character excluding the caster spellfunc (callable): Function that performs the action of the spell. - Must take the following arguments: caster (obj), targets (list), - and cost(int). + Must take the following arguments: caster (obj), spell_name (str), targets (list), + and cost(int), as well as **kwargs. Optional values for spells: @@ -957,5 +1017,6 @@ Any other values specified besides the above will be passed as kwargs to the spe SPELLS = { "magic missile":{"spellfunc":None, "target":"otherchar", "cost":3, "noncombat_spell":False, "max_targets":3}, -"cure wounds":{"spellfunc":None, "target":"anychar", "cost":5}, -} \ No newline at end of file +"cure wounds":{"spellfunc":spell_cure_wounds, "target":"anychar", "cost":5, "its_a_kwarg":"wow"}, +} + From 792c54996a0fe0e2a9841cb17b5b5183bbfa3e6a Mon Sep 17 00:00:00 2001 From: FlutterSprite Date: Sun, 12 Nov 2017 21:11:28 -0800 Subject: [PATCH 003/208] Added attack spells, more healing spell variants --- evennia/contrib/turnbattle/tb_magic.py | 134 ++++++++++++++++++++++--- 1 file changed, 120 insertions(+), 14 deletions(-) diff --git a/evennia/contrib/turnbattle/tb_magic.py b/evennia/contrib/turnbattle/tb_magic.py index 7b59393e23..bb451b1e96 100644 --- a/evennia/contrib/turnbattle/tb_magic.py +++ b/evennia/contrib/turnbattle/tb_magic.py @@ -753,10 +753,16 @@ class CmdCast(MuxCommand): """ caller = self.caller - syntax_err = "Usage: cast [= ]" if not self.lhs or len(self.lhs) < 3: # No spell name given - self.caller.msg(syntax_err) - return + caller.msg("Usage: cast = , , ...") + if not caller.db.spells_known: + caller.msg("You don't know any spells.") + return + else: + caller.db.spells_known = sorted(caller.db.spells_known) + spells_known_msg = "You know the following spells:|/" + "|/".join(caller.db.spells_known) + caller.msg(spells_known_msg) # List the spells the player knows + return spellname = self.lhs.lower() spell_to_cast = [] @@ -809,6 +815,7 @@ class CmdCast(MuxCommand): # If caster doesn't have enough MP to cover the spell's cost, give error and return if spelldata["cost"] > caller.db.mp: caller.msg("You don't have enough MP to cast '%s'." % spell_to_cast) + return # If in combat and the spell isn't a combat spell, give error message and return if spelldata["combat_spell"] == False and is_in_combat(caller): @@ -894,13 +901,13 @@ class CmdCast(MuxCommand): class CmdRest(Command): """ - Recovers damage. + Recovers damage and restores MP. Usage: rest - Resting recovers your HP to its maximum, but you can only - rest if you're not in a fight. + Resting recovers your HP and MP to their maximum, but you can + only rest if you're not in a fight. """ key = "rest" @@ -914,9 +921,10 @@ class CmdRest(Command): return self.caller.db.hp = self.caller.db.max_hp # Set current HP to maximum - self.caller.location.msg_contents("%s rests to recover HP." % self.caller) + self.caller.db.mp = self.caller.db.max_mp # Set current MP to maximum + self.caller.location.msg_contents("%s rests to recover HP and MP." % self.caller) """ - You'll probably want to replace this with your own system for recovering HP. + You'll probably want to replace this with your own system for recovering HP and MP. """ @@ -974,8 +982,16 @@ def spell_cure_wounds(caster, spell_name, targets, cost, **kwargs): """ spell_msg = "%s casts %s!" % (caster, spell_name) + min_healing = 20 + max_healing = 40 + + # Retrieve healing range from kwargs, if present + if "healing_range" in kwargs: + min_healing = kwargs["healing_range"][0] + max_healing = kwargs["healing_range"][1] + for character in targets: - to_heal = randint(20, 40) # Restore 20 to 40 hp + to_heal = randint(min_healing, max_healing) # Restore 20 to 40 hp if character.db.hp + to_heal > character.db.max_hp: to_heal = character.db.max_hp - character.db.hp # Cap healing to max HP character.db.hp += to_heal @@ -986,7 +1002,91 @@ def spell_cure_wounds(caster, spell_name, targets, cost, **kwargs): caster.location.msg_contents(spell_msg) # Message the room with spell results if is_in_combat(caster): # Spend action if in combat - spend_action(caster, 1, action_name="cast") + spend_action(caster, 1, action_name="cast") + +def spell_attack(caster, spell_name, targets, cost, **kwargs): + """ + Spell that deals damage in combat. Similar to resolve_attack. + """ + spell_msg = "%s casts %s!" % (caster, spell_name) + + atkname_single = "The spell" + atkname_plural = "spells" + min_damage = 10 + max_damage = 20 + accuracy = 0 + attack_count = 1 + + # Retrieve some variables from kwargs, if present + if "attack_name" in kwargs: + atkname_single = kwargs["attack_name"][0] + atkname_plural = kwargs["attack_name"][1] + if "damage_range" in kwargs: + min_damage = kwargs["damage_range"][0] + max_damage = kwargs["damage_range"][1] + if "accuracy" in kwargs: + accuracy = kwargs["accuracy"] + if "attack_count" in kwargs: + attack_count = kwargs["attack_count"] + + to_attack = [] + print targets + # If there are more attacks than targets given, attack first target multiple times + if len(targets) < attack_count: + to_attack = to_attack + targets + extra_attacks = attack_count - len(targets) + for n in range(extra_attacks): + to_attack.insert(0, targets[0]) + else: + to_attack = targets + + print to_attack + print targets + + # Set up dictionaries to track number of hits and total damage + total_hits = {} + total_damage = {} + for fighter in targets: + total_hits.update({fighter:0}) + total_damage.update({fighter:0}) + + # Resolve attack for each target + for fighter in to_attack: + attack_value = randint(1, 100) + accuracy # Spell attack roll + defense_value = get_defense(caster, fighter) + if attack_value >= defense_value: + spell_dmg = randint(min_damage, max_damage) # Get spell damage + total_hits[fighter] += 1 + total_damage[fighter] += spell_dmg + + print total_hits + print total_damage + print targets + + for fighter in targets: + # Construct combat message + if total_hits[fighter] == 0: + spell_msg += " The spell misses %s!" % fighter + elif total_hits[fighter] > 0: + attack_count_str = atkname_single + " hits" + if total_hits[fighter] > 1: + attack_count_str = "%i %s hit" % (total_hits[fighter], atkname_plural) + spell_msg += " %s %s for %i damage!" % (attack_count_str, fighter, total_damage[fighter]) + + caster.db.mp -= cost # Deduct MP cost + + caster.location.msg_contents(spell_msg) # Message the room with spell results + + for fighter in targets: + # Apply damage + apply_damage(fighter, total_damage[fighter]) + # If fighter HP is reduced to 0 or less, call at_defeat. + if fighter.db.hp <= 0: + at_defeat(fighter) + + if is_in_combat(caster): # Spend action if in combat + spend_action(caster, 1, action_name="cast") + """ Required values for spells: @@ -1002,7 +1102,7 @@ Required values for spells: "otherchar" - Any character excluding the caster spellfunc (callable): Function that performs the action of the spell. Must take the following arguments: caster (obj), spell_name (str), targets (list), - and cost(int), as well as **kwargs. + and cost (int), as well as **kwargs. Optional values for spells: @@ -1012,11 +1112,17 @@ Optional values for spells: 1 by default - unused if target is "none" or "self" Any other values specified besides the above will be passed as kwargs to the spellfunc. - +You can use kwargs to effectively re-use the same function for different but similar +spells. """ SPELLS = { -"magic missile":{"spellfunc":None, "target":"otherchar", "cost":3, "noncombat_spell":False, "max_targets":3}, -"cure wounds":{"spellfunc":spell_cure_wounds, "target":"anychar", "cost":5, "its_a_kwarg":"wow"}, +"magic missile":{"spellfunc":spell_attack, "target":"otherchar", "cost":3, "noncombat_spell":False, "max_targets":3, + "attack_name":("A bolt", "bolts"), "damage_range":(4, 7), "accuracy":999, "attack_count":3}, +"flame shot":{"spellfunc":spell_attack, "target":"otherchar", "cost":3, "noncombat_spell":False, + "attack_name":("A jet of flame", "jets of flame"), "damage_range":(25, 35)}, +"cure wounds":{"spellfunc":spell_cure_wounds, "target":"anychar", "cost":5}, +"mass cure wounds":{"spellfunc":spell_cure_wounds, "target":"anychar", "cost":10, "max_targets": 5}, +"full heal":{"spellfunc":spell_cure_wounds, "target":"anychar", "cost":12, "healing_range":(100, 100)} } From 19c278afcdc921c50e9bbab367702d2600b6673c Mon Sep 17 00:00:00 2001 From: FlutterSprite Date: Wed, 15 Nov 2017 14:14:16 -0800 Subject: [PATCH 004/208] Formatting and documentation --- evennia/contrib/turnbattle/tb_magic.py | 206 +++++++++++++++++++------ 1 file changed, 160 insertions(+), 46 deletions(-) diff --git a/evennia/contrib/turnbattle/tb_magic.py b/evennia/contrib/turnbattle/tb_magic.py index bb451b1e96..c2222645ac 100644 --- a/evennia/contrib/turnbattle/tb_magic.py +++ b/evennia/contrib/turnbattle/tb_magic.py @@ -1,20 +1,38 @@ """ -Simple turn-based combat system +Simple turn-based combat system with spell casting Contrib - Tim Ashley Jenkins 2017 -This is a framework for a simple turn-based combat system, similar -to those used in D&D-style tabletop role playing games. It allows -any character to start a fight in a room, at which point initiative -is rolled and a turn order is established. Each participant in combat -has a limited time to decide their action for that turn (30 seconds by -default), and combat progresses through the turn order, looping through -the participants until the fight ends. +This is a version of the 'turnbattle' contrib that includes a basic, +expandable framework for a 'magic system', whereby players can spend +a limited resource (MP) to achieve a wide variety of effects, both in +and out of combat. This does not have to strictly be a system for +magic - it can easily be re-flavored to any other sort of resource +based mechanic, like psionic powers, special moves and stamina, and +so forth. -Only simple rolls for attacking are implemented here, but this system -is easily extensible and can be used as the foundation for implementing -the rules from your turn-based tabletop game of choice or making your -own battle system. +In this system, spells are learned by name with the 'learnspell' +command, and then used with the 'cast' command. Spells can be cast in or +out of combat - some spells can only be cast in combat, some can only be +cast outside of combat, and some can be cast any time. However, if you +are in combat, you can only cast a spell on your turn, and doing so will +typically use an action (as specified in the spell's funciton). + +Spells are defined at the end of the module in a database that's a +dictionary of dictionaries - each spell is matched by name to a function, +along with various parameters that restrict when the spell can be used and +what the spell can be cast on. Included is a small variety of spells that +damage opponents and heal HP, as well as one that creates an object. + +Because a spell can call any function, a spell can be made to do just +about anything at all. The SPELLS dictionary at the bottom of the module +even allows kwargs to be passed to the spell function, so that the same +function can be re-used for multiple similar spells. + +Spells in this system work on a very basic resource: MP, which is spent +when casting spells and restored by resting. It shouldn't be too difficult +to modify this system to use spell slots, some physical fuel or resource, +or whatever else your game requires. To install and test, import this module's TBMagicCharacter object into your game's character.py module: @@ -43,7 +61,7 @@ in your game and using it as-is. """ from random import randint -from evennia import DefaultCharacter, Command, default_cmds, DefaultScript +from evennia import DefaultCharacter, Command, default_cmds, DefaultScript, create_object from evennia.commands.default.muxcommand import MuxCommand from evennia.commands.default.help import CmdHelp @@ -690,7 +708,12 @@ class CmdDisengage(Command): class CmdLearnSpell(Command): """ - Learn a magic spell + Learn a magic spell. + + Usage: + learnspell + + Adds a spell by name to your list of spells known. """ key = "learnspell" @@ -706,7 +729,7 @@ class CmdLearnSpell(Command): caller = self.caller spell_to_learn = [] - if not args or len(args) < 3: + if not args or len(args) < 3: # No spell given caller.msg("Usage: learnspell ") return @@ -725,23 +748,29 @@ class CmdLearnSpell(Command): if len(spell_to_learn) == 1: # If one match, extract the string spell_to_learn = spell_to_learn[0] - if spell_to_learn not in self.caller.db.spells_known: - caller.db.spells_known.append(spell_to_learn) + if spell_to_learn not in self.caller.db.spells_known: # If the spell isn't known... + caller.db.spells_known.append(spell_to_learn) # ...then add the spell to the character caller.msg("You learn the spell '%s'!" % spell_to_learn) return - if spell_to_learn in self.caller.db.spells_known: - caller.msg("You already know the spell '%s'!" % spell_to_learn) + if spell_to_learn in self.caller.db.spells_known: # Already has the spell specified + caller.msg("You already know the spell '%s'!" % spell_to_learn) + """ + You will almost definitely want to replace this with your own system + for learning spells, perhaps tied to character advancement or finding + items in the game world that spells can be learned from. + """ class CmdCast(MuxCommand): """ - Cast a magic spell! + Cast a magic spell that you know, provided you have the MP + to spend on its casting. - Notes: This is a quite long command, since it has to cope with all - the different circumstances in which you may or may not be able - to cast a spell. None of the spell's effects are handled by the - command - all the command does is verify that the player's input - is valid for the spell being cast and then call the spell's - function. + Usage: + cast [= , , etc...] + + Some spells can be cast on multiple targets, some can be cast + on only yourself, and some don't need a target specified at all. + Typing 'cast' by itself will give you a list of spells you know. """ key = "cast" @@ -829,7 +858,7 @@ class CmdCast(MuxCommand): # If spell takes no targets and one is given, give error message and return if len(spell_targets) > 0 and spelldata["target"] == "none": - caller.msg("The spell '%s' isn't cast on a target.") + caller.msg("The spell '%s' isn't cast on a target." % spell_to_cast) return # If no target is given and spell requires a target, give error message @@ -895,8 +924,16 @@ class CmdCast(MuxCommand): caller.msg("You can't specify the same target more than once!") return - # Finally, we can cast the spell itself + # Finally, we can cast the spell itself. Note that MP is not deducted here! spelldata["spellfunc"](caller, spell_to_cast, spell_targets, spelldata["cost"], **kwargs) + """ + Note: This is a quite long command, since it has to cope with all + the different circumstances in which you may or may not be able + to cast a spell. None of the spell's effects are handled by the + command - all the command does is verify that the player's input + is valid for the spell being cast and then call the spell's + function. + """ class CmdRest(Command): @@ -973,12 +1010,32 @@ class BattleCmdSet(default_cmds.CharacterCmdSet): self.add(CmdCast()) """ +---------------------------------------------------------------------------- SPELL FUNCTIONS START HERE +---------------------------------------------------------------------------- + +These are the functions that are called by the 'cast' command to perform the +effects of various spells. Which spells execute which functions and what +parameters are passed to them are specified at the bottom of the module, in +the 'SPELLS' dictionary. + +All of these functions take the same arguments: + caster (obj): Character casting the spell + spell_name (str): Name of the spell being cast + targets (list): List of objects targeted by the spell + cost (int): MP cost of casting the spell + +These functions also all accept **kwargs, and how these are used is specified +in the docstring for each function. """ -def spell_cure_wounds(caster, spell_name, targets, cost, **kwargs): +def spell_healing(caster, spell_name, targets, cost, **kwargs): """ - Spell that restores HP to a target. + Spell that restores HP to a target or targets. + + kwargs: + healing_range (tuple): Minimum and maximum amount healed to + each target. (20, 40) by default. """ spell_msg = "%s casts %s!" % (caster, spell_name) @@ -1007,6 +1064,20 @@ def spell_cure_wounds(caster, spell_name, targets, cost, **kwargs): def spell_attack(caster, spell_name, targets, cost, **kwargs): """ Spell that deals damage in combat. Similar to resolve_attack. + + kwargs: + attack_name (tuple): Single and plural describing the sort of + attack or projectile that strikes each enemy. + damage_range (tuple): Minimum and maximum damage dealt by the + spell. (10, 20) by default. + accuracy (int): Modifier to the spell's attack roll, determining + an increased or decreased chance to hit. 0 by default. + attack_count (int): How many individual attacks are made as part + of the spell. If the number of attacks exceeds the number of + targets, the first target specified will be attacked more + than once. Just 1 by default - if the attack_count is less + than the number targets given, each target will only be + attacked once. """ spell_msg = "%s casts %s!" % (caster, spell_name) @@ -1030,7 +1101,6 @@ def spell_attack(caster, spell_name, targets, cost, **kwargs): attack_count = kwargs["attack_count"] to_attack = [] - print targets # If there are more attacks than targets given, attack first target multiple times if len(targets) < attack_count: to_attack = to_attack + targets @@ -1038,10 +1108,8 @@ def spell_attack(caster, spell_name, targets, cost, **kwargs): for n in range(extra_attacks): to_attack.insert(0, targets[0]) else: - to_attack = targets + to_attack = to_attack + targets - print to_attack - print targets # Set up dictionaries to track number of hits and total damage total_hits = {} @@ -1058,10 +1126,6 @@ def spell_attack(caster, spell_name, targets, cost, **kwargs): spell_dmg = randint(min_damage, max_damage) # Get spell damage total_hits[fighter] += 1 total_damage[fighter] += spell_dmg - - print total_hits - print total_damage - print targets for fighter in targets: # Construct combat message @@ -1087,11 +1151,54 @@ def spell_attack(caster, spell_name, targets, cost, **kwargs): if is_in_combat(caster): # Spend action if in combat spend_action(caster, 1, action_name="cast") +def spell_conjure(caster, spell_name, targets, cost, **kwargs): + """ + Spell that creates an object. + + kwargs: + obj_key (str): Key of the created object. + obj_desc (str): Desc of the created object. + obj_typeclass (str): Typeclass path of the object. + + If you want to make more use of this particular spell funciton, + you may want to modify it to use the spawner (in evennia.utils.spawner) + instead of creating objects directly. + """ + + obj_key = "a nondescript object" + obj_desc = "A perfectly generic object." + obj_typeclass = "evennia.objects.objects.DefaultObject" + + # Retrieve some variables from kwargs, if present + if "obj_key" in kwargs: + obj_key = kwargs["obj_key"] + if "obj_desc" in kwargs: + obj_desc = kwargs["obj_desc"] + if "obj_typeclass" in kwargs: + obj_typeclass = kwargs["obj_typeclass"] + + conjured_obj = create_object(obj_typeclass, key=obj_key, location=caster.location) # Create object + conjured_obj.db.desc = obj_desc # Add object desc + + caster.db.mp -= cost # Deduct MP cost + + caster.location.msg_contents("%s casts %s, and %s appears!" % (caster, spell_name, conjured_obj)) """ +---------------------------------------------------------------------------- +SPELL DEFINITIONS START HERE +---------------------------------------------------------------------------- +In this section, each spell is matched to a function, and given parameters +that determine its MP cost, valid type and number of targets, and what +function casting the spell executes. + +This data is given as a dictionary of dictionaries - the key of each entry +is the spell's name, and the value is a dictionary of various options and +parameters, some of which are required and others which are optional. + Required values for spells: - cost (int): MP cost of casting the spell + cost (int): MP cost of casting the spell target (str): Valid targets for the spell. Can be any of: "none" - No target needed "self" - Self only @@ -1101,8 +1208,8 @@ Required values for spells: "other" - Any object excluding the caster "otherchar" - Any character excluding the caster spellfunc (callable): Function that performs the action of the spell. - Must take the following arguments: caster (obj), spell_name (str), targets (list), - and cost (int), as well as **kwargs. + Must take the following arguments: caster (obj), spell_name (str), + targets (list), and cost (int), as well as **kwargs. Optional values for spells: @@ -1115,14 +1222,21 @@ Any other values specified besides the above will be passed as kwargs to the spe You can use kwargs to effectively re-use the same function for different but similar spells. """ - + SPELLS = { "magic missile":{"spellfunc":spell_attack, "target":"otherchar", "cost":3, "noncombat_spell":False, "max_targets":3, "attack_name":("A bolt", "bolts"), "damage_range":(4, 7), "accuracy":999, "attack_count":3}, + "flame shot":{"spellfunc":spell_attack, "target":"otherchar", "cost":3, "noncombat_spell":False, - "attack_name":("A jet of flame", "jets of flame"), "damage_range":(25, 35)}, -"cure wounds":{"spellfunc":spell_cure_wounds, "target":"anychar", "cost":5}, -"mass cure wounds":{"spellfunc":spell_cure_wounds, "target":"anychar", "cost":10, "max_targets": 5}, -"full heal":{"spellfunc":spell_cure_wounds, "target":"anychar", "cost":12, "healing_range":(100, 100)} + "attack_name":("A jet of flame", "jets of flame"), "damage_range":(25, 35)}, + +"cure wounds":{"spellfunc":spell_healing, "target":"anychar", "cost":5}, + +"mass cure wounds":{"spellfunc":spell_healing, "target":"anychar", "cost":10, "max_targets": 5}, + +"full heal":{"spellfunc":spell_healing, "target":"anychar", "cost":12, "healing_range":(100, 100)}, + +"cactus conjuration":{"spellfunc":spell_conjure, "target":"none", "cost":2, "combat_spell":False, + "obj_key":"a cactus", "obj_desc":"An ordinary green cactus with little spines."} } From 473fbe8a812401694bd6bb3cf49aae3af56ff221 Mon Sep 17 00:00:00 2001 From: BattleJenkins Date: Wed, 15 Nov 2017 14:41:43 -0800 Subject: [PATCH 005/208] Comments and documentation, CmdStatus() added --- evennia/contrib/turnbattle/tb_magic.py | 41 +++++++++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/evennia/contrib/turnbattle/tb_magic.py b/evennia/contrib/turnbattle/tb_magic.py index c2222645ac..eabf8c0932 100644 --- a/evennia/contrib/turnbattle/tb_magic.py +++ b/evennia/contrib/turnbattle/tb_magic.py @@ -714,6 +714,22 @@ class CmdLearnSpell(Command): learnspell Adds a spell by name to your list of spells known. + + The following spells are provided as examples: + + |wmagic missile|n (3 MP): Fires three missiles that never miss. Can target + up to three different enemies. + + |wflame shot|n (3 MP): Shoots a high-damage jet of flame at one target. + + |wcure wounds|n (5 MP): Heals damage on one target. + + |wmass cure wounds|n (10 MP): Like 'cure wounds', but can heal up to 5 + targets at once. + + |wfull heal|n (12 MP): Heals one target back to full HP. + + |wcactus conjuration|n (2 MP): Creates a cactus. """ key = "learnspell" @@ -925,7 +941,10 @@ class CmdCast(MuxCommand): return # Finally, we can cast the spell itself. Note that MP is not deducted here! - spelldata["spellfunc"](caller, spell_to_cast, spell_targets, spelldata["cost"], **kwargs) + try: + spelldata["spellfunc"](caller, spell_to_cast, spell_targets, spelldata["cost"], **kwargs) + except Exception: + log_trace("Error in callback for spell: %s." % spell_to_cast) """ Note: This is a quite long command, since it has to cope with all the different circumstances in which you may or may not be able @@ -963,7 +982,25 @@ class CmdRest(Command): """ You'll probably want to replace this with your own system for recovering HP and MP. """ + +class CmdStatus(Command): + """ + Gives combat information. + Usage: + status + + Shows your current and maximum HP and your distance from + other targets in combat. + """ + + key = "status" + help_category = "combat" + + def func(self): + "This performs the actual command." + char = self.caller + char.msg("You have %i / %i HP and %i / %i MP." % (char.db.hp, char.db.max_hp, char.db.mp, char.db.max_mp)) class CmdCombatHelp(CmdHelp): """ @@ -1008,6 +1045,7 @@ class BattleCmdSet(default_cmds.CharacterCmdSet): self.add(CmdCombatHelp()) self.add(CmdLearnSpell()) self.add(CmdCast()) + self.add(CmdStatus()) """ ---------------------------------------------------------------------------- @@ -1182,6 +1220,7 @@ def spell_conjure(caster, spell_name, targets, cost, **kwargs): caster.db.mp -= cost # Deduct MP cost + # Message the room to announce the creation of the object caster.location.msg_contents("%s casts %s, and %s appears!" % (caster, spell_name, conjured_obj)) """ From 89ed833c76790d0f75f8cc2bcfd612884c004f82 Mon Sep 17 00:00:00 2001 From: BattleJenkins Date: Wed, 15 Nov 2017 14:58:25 -0800 Subject: [PATCH 006/208] Final touches --- evennia/contrib/turnbattle/tb_magic.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/evennia/contrib/turnbattle/tb_magic.py b/evennia/contrib/turnbattle/tb_magic.py index eabf8c0932..6e16cd0d46 100644 --- a/evennia/contrib/turnbattle/tb_magic.py +++ b/evennia/contrib/turnbattle/tb_magic.py @@ -769,7 +769,7 @@ class CmdLearnSpell(Command): caller.msg("You learn the spell '%s'!" % spell_to_learn) return if spell_to_learn in self.caller.db.spells_known: # Already has the spell specified - caller.msg("You already know the spell '%s'!" % spell_to_learn) + caller.msg("You already know the spell '%s'!" % spell_to_learn) """ You will almost definitely want to replace this with your own system for learning spells, perhaps tied to character advancement or finding @@ -1257,9 +1257,13 @@ Optional values for spells: max_targets (int): Maximum number of objects that can be targeted by the spell. 1 by default - unused if target is "none" or "self" -Any other values specified besides the above will be passed as kwargs to the spellfunc. +Any other values specified besides the above will be passed as kwargs to 'spellfunc'. You can use kwargs to effectively re-use the same function for different but similar -spells. +spells - for example, 'magic missile' and 'flame shot' use the same function, but +behave differently, as they have different damage ranges, accuracy, amount of attacks +made as part of the spell, and so forth. If you make your spell functions flexible +enough, you can make a wide variety of spells just by adding more entries to this +dictionary. """ SPELLS = { From 97ab816508d9f59dd2f18a69a63c950e7b989c7b Mon Sep 17 00:00:00 2001 From: FlutterSprite Date: Wed, 15 Nov 2017 16:25:08 -0800 Subject: [PATCH 007/208] Create tb_items.py --- evennia/contrib/turnbattle/tb_items.py | 754 +++++++++++++++++++++++++ 1 file changed, 754 insertions(+) create mode 100644 evennia/contrib/turnbattle/tb_items.py diff --git a/evennia/contrib/turnbattle/tb_items.py b/evennia/contrib/turnbattle/tb_items.py new file mode 100644 index 0000000000..d4883e4c99 --- /dev/null +++ b/evennia/contrib/turnbattle/tb_items.py @@ -0,0 +1,754 @@ +""" +Simple turn-based combat system + +Contrib - Tim Ashley Jenkins 2017 + +This is a framework for a simple turn-based combat system, similar +to those used in D&D-style tabletop role playing games. It allows +any character to start a fight in a room, at which point initiative +is rolled and a turn order is established. Each participant in combat +has a limited time to decide their action for that turn (30 seconds by +default), and combat progresses through the turn order, looping through +the participants until the fight ends. + +Only simple rolls for attacking are implemented here, but this system +is easily extensible and can be used as the foundation for implementing +the rules from your turn-based tabletop game of choice or making your +own battle system. + +To install and test, import this module's TBBasicCharacter object into +your game's character.py module: + + from evennia.contrib.turnbattle.tb_basic import TBBasicCharacter + +And change your game's character typeclass to inherit from TBBasicCharacter +instead of the default: + + class Character(TBBasicCharacter): + +Next, import this module into your default_cmdsets.py module: + + from evennia.contrib.turnbattle import tb_basic + +And add the battle command set to your default command set: + + # + # any commands you add below will overload the default ones. + # + self.add(tb_basic.BattleCmdSet()) + +This module is meant to be heavily expanded on, so you may want to copy it +to your game's 'world' folder and modify it there rather than importing it +in your game and using it as-is. +""" + +from random import randint +from evennia import DefaultCharacter, Command, default_cmds, DefaultScript +from evennia.commands.default.help import CmdHelp + +""" +---------------------------------------------------------------------------- +OPTIONS +---------------------------------------------------------------------------- +""" + +TURN_TIMEOUT = 30 # Time before turns automatically end, in seconds +ACTIONS_PER_TURN = 1 # Number of actions allowed per turn + +""" +---------------------------------------------------------------------------- +COMBAT FUNCTIONS START HERE +---------------------------------------------------------------------------- +""" + +def roll_init(character): + """ + Rolls a number between 1-1000 to determine initiative. + + Args: + character (obj): The character to determine initiative for + + Returns: + initiative (int): The character's place in initiative - higher + numbers go first. + + Notes: + By default, does not reference the character and simply returns + a random integer from 1 to 1000. + + Since the character is passed to this function, you can easily reference + a character's stats to determine an initiative roll - for example, if your + character has a 'dexterity' attribute, you can use it to give that character + an advantage in turn order, like so: + + return (randint(1,20)) + character.db.dexterity + + This way, characters with a higher dexterity will go first more often. + """ + return randint(1, 1000) + + +def get_attack(attacker, defender): + """ + Returns a value for an attack roll. + + Args: + attacker (obj): Character doing the attacking + defender (obj): Character being attacked + + Returns: + attack_value (int): Attack roll value, compared against a defense value + to determine whether an attack hits or misses. + + Notes: + By default, returns a random integer from 1 to 100 without using any + properties from either the attacker or defender. + + This can easily be expanded to return a value based on characters stats, + equipment, and abilities. This is why the attacker and defender are passed + to this function, even though nothing from either one are used in this example. + """ + # For this example, just return a random integer up to 100. + attack_value = randint(1, 100) + return attack_value + + +def get_defense(attacker, defender): + """ + Returns a value for defense, which an attack roll must equal or exceed in order + for an attack to hit. + + Args: + attacker (obj): Character doing the attacking + defender (obj): Character being attacked + + Returns: + defense_value (int): Defense value, compared against an attack roll + to determine whether an attack hits or misses. + + Notes: + By default, returns 50, not taking any properties of the defender or + attacker into account. + + As above, this can be expanded upon based on character stats and equipment. + """ + # For this example, just return 50, for about a 50/50 chance of hit. + defense_value = 50 + return defense_value + + +def get_damage(attacker, defender): + """ + Returns a value for damage to be deducted from the defender's HP after abilities + successful hit. + + Args: + attacker (obj): Character doing the attacking + defender (obj): Character being damaged + + Returns: + damage_value (int): Damage value, which is to be deducted from the defending + character's HP. + + Notes: + By default, returns a random integer from 15 to 25 without using any + properties from either the attacker or defender. + + Again, this can be expanded upon. + """ + # For this example, just generate a number between 15 and 25. + damage_value = randint(15, 25) + return damage_value + + +def apply_damage(defender, damage): + """ + Applies damage to a target, reducing their HP by the damage amount to a + minimum of 0. + + Args: + defender (obj): Character taking damage + damage (int): Amount of damage being taken + """ + defender.db.hp -= damage # Reduce defender's HP by the damage dealt. + # If this reduces it to 0 or less, set HP to 0. + if defender.db.hp <= 0: + defender.db.hp = 0 + +def at_defeat(defeated): + """ + Announces the defeat of a fighter in combat. + + Args: + defeated (obj): Fighter that's been defeated. + + Notes: + All this does is announce a defeat message by default, but if you + want anything else to happen to defeated fighters (like putting them + into a dying state or something similar) then this is the place to + do it. + """ + defeated.location.msg_contents("%s has been defeated!" % defeated) + +def resolve_attack(attacker, defender, attack_value=None, defense_value=None): + """ + Resolves an attack and outputs the result. + + Args: + attacker (obj): Character doing the attacking + defender (obj): Character being attacked + + Notes: + Even though the attack and defense values are calculated + extremely simply, they are separated out into their own functions + so that they are easier to expand upon. + """ + # Get an attack roll from the attacker. + if not attack_value: + attack_value = get_attack(attacker, defender) + # Get a defense value from the defender. + if not defense_value: + defense_value = get_defense(attacker, defender) + # If the attack value is lower than the defense value, miss. Otherwise, hit. + if attack_value < defense_value: + attacker.location.msg_contents("%s's attack misses %s!" % (attacker, defender)) + else: + damage_value = get_damage(attacker, defender) # Calculate damage value. + # Announce damage dealt and apply damage. + attacker.location.msg_contents("%s hits %s for %i damage!" % (attacker, defender, damage_value)) + apply_damage(defender, damage_value) + # If defender HP is reduced to 0 or less, call at_defeat. + if defender.db.hp <= 0: + at_defeat(defender) + +def combat_cleanup(character): + """ + Cleans up all the temporary combat-related attributes on a character. + + Args: + character (obj): Character to have their combat attributes removed + + Notes: + Any attribute whose key begins with 'combat_' is temporary and no + longer needed once a fight ends. + """ + for attr in character.attributes.all(): + if attr.key[:7] == "combat_": # If the attribute name starts with 'combat_'... + character.attributes.remove(key=attr.key) # ...then delete it! + + +def is_in_combat(character): + """ + Returns true if the given character is in combat. + + Args: + character (obj): Character to determine if is in combat or not + + Returns: + (bool): True if in combat or False if not in combat + """ + return bool(character.db.combat_turnhandler) + + +def is_turn(character): + """ + Returns true if it's currently the given character's turn in combat. + + Args: + character (obj): Character to determine if it is their turn or not + + Returns: + (bool): True if it is their turn or False otherwise + """ + turnhandler = character.db.combat_turnhandler + currentchar = turnhandler.db.fighters[turnhandler.db.turn] + return bool(character == currentchar) + + +def spend_action(character, actions, action_name=None): + """ + Spends a character's available combat actions and checks for end of turn. + + Args: + character (obj): Character spending the action + actions (int) or 'all': Number of actions to spend, or 'all' to spend all actions + + Kwargs: + action_name (str or None): If a string is given, sets character's last action in + combat to provided string + """ + if action_name: + character.db.combat_lastaction = action_name + if actions == 'all': # If spending all actions + character.db.combat_actionsleft = 0 # Set actions to 0 + else: + character.db.combat_actionsleft -= actions # Use up actions. + if character.db.combat_actionsleft < 0: + character.db.combat_actionsleft = 0 # Can't have fewer than 0 actions + character.db.combat_turnhandler.turn_end_check(character) # Signal potential end of turn. + + +""" +---------------------------------------------------------------------------- +CHARACTER TYPECLASS +---------------------------------------------------------------------------- +""" + + +class TBBasicCharacter(DefaultCharacter): + """ + A character able to participate in turn-based combat. Has attributes for current + and maximum HP, and access to combat commands. + """ + + def at_object_creation(self): + """ + Called once, when this object is first created. This is the + normal hook to overload for most object types. + """ + self.db.max_hp = 100 # Set maximum HP to 100 + self.db.hp = self.db.max_hp # Set current HP to maximum + """ + Adds attributes for a character's current and maximum HP. + We're just going to set this value at '100' by default. + + You may want to expand this to include various 'stats' that + can be changed at creation and factor into combat calculations. + """ + + def at_before_move(self, destination): + """ + Called just before starting to move this object to + destination. + + Args: + destination (Object): The object we are moving to + + Returns: + shouldmove (bool): If we should move or not. + + Notes: + If this method returns False/None, the move is cancelled + before it is even started. + + """ + # Keep the character from moving if at 0 HP or in combat. + if is_in_combat(self): + self.msg("You can't exit a room while in combat!") + return False # Returning false keeps the character from moving. + if self.db.HP <= 0: + self.msg("You can't move, you've been defeated!") + return False + return True + +""" +---------------------------------------------------------------------------- +SCRIPTS START HERE +---------------------------------------------------------------------------- +""" + + +class TBBasicTurnHandler(DefaultScript): + """ + This is the script that handles the progression of combat through turns. + On creation (when a fight is started) it adds all combat-ready characters + to its roster and then sorts them into a turn order. There can only be one + fight going on in a single room at a time, so the script is assigned to a + room as its object. + + Fights persist until only one participant is left with any HP or all + remaining participants choose to end the combat with the 'disengage' command. + """ + + def at_script_creation(self): + """ + Called once, when the script is created. + """ + self.key = "Combat Turn Handler" + self.interval = 5 # Once every 5 seconds + self.persistent = True + self.db.fighters = [] + + # Add all fighters in the room with at least 1 HP to the combat." + for thing in self.obj.contents: + if thing.db.hp: + self.db.fighters.append(thing) + + # Initialize each fighter for combat + for fighter in self.db.fighters: + self.initialize_for_combat(fighter) + + # Add a reference to this script to the room + self.obj.db.combat_turnhandler = self + + # Roll initiative and sort the list of fighters depending on who rolls highest to determine turn order. + # The initiative roll is determined by the roll_init function and can be customized easily. + ordered_by_roll = sorted(self.db.fighters, key=roll_init, reverse=True) + self.db.fighters = ordered_by_roll + + # Announce the turn order. + self.obj.msg_contents("Turn order is: %s " % ", ".join(obj.key for obj in self.db.fighters)) + + # Start first fighter's turn. + self.start_turn(self.db.fighters[0]) + + # Set up the current turn and turn timeout delay. + self.db.turn = 0 + self.db.timer = TURN_TIMEOUT # Set timer to turn timeout specified in options + + def at_stop(self): + """ + Called at script termination. + """ + for fighter in self.db.fighters: + combat_cleanup(fighter) # Clean up the combat attributes for every fighter. + self.obj.db.combat_turnhandler = None # Remove reference to turn handler in location + + def at_repeat(self): + """ + Called once every self.interval seconds. + """ + currentchar = self.db.fighters[self.db.turn] # Note the current character in the turn order. + self.db.timer -= self.interval # Count down the timer. + + if self.db.timer <= 0: + # Force current character to disengage if timer runs out. + self.obj.msg_contents("%s's turn timed out!" % currentchar) + spend_action(currentchar, 'all', action_name="disengage") # Spend all remaining actions. + return + elif self.db.timer <= 10 and not self.db.timeout_warning_given: # 10 seconds left + # Warn the current character if they're about to time out. + currentchar.msg("WARNING: About to time out!") + self.db.timeout_warning_given = True + + def initialize_for_combat(self, character): + """ + Prepares a character for combat when starting or entering a fight. + + Args: + character (obj): Character to initialize for combat. + """ + combat_cleanup(character) # Clean up leftover combat attributes beforehand, just in case. + character.db.combat_actionsleft = 0 # Actions remaining - start of turn adds to this, turn ends when it reaches 0 + character.db.combat_turnhandler = self # Add a reference to this turn handler script to the character + character.db.combat_lastaction = "null" # Track last action taken in combat + + def start_turn(self, character): + """ + Readies a character for the start of their turn by replenishing their + available actions and notifying them that their turn has come up. + + Args: + character (obj): Character to be readied. + + Notes: + Here, you only get one action per turn, but you might want to allow more than + one per turn, or even grant a number of actions based on a character's + attributes. You can even add multiple different kinds of actions, I.E. actions + separated for movement, by adding "character.db.combat_movesleft = 3" or + something similar. + """ + character.db.combat_actionsleft = ACTIONS_PER_TURN # Replenish actions + # Prompt the character for their turn and give some information. + character.msg("|wIt's your turn! You have %i HP remaining.|n" % character.db.hp) + + def next_turn(self): + """ + Advances to the next character in the turn order. + """ + + # Check to see if every character disengaged as their last action. If so, end combat. + disengage_check = True + for fighter in self.db.fighters: + if fighter.db.combat_lastaction != "disengage": # If a character has done anything but disengage + disengage_check = False + if disengage_check: # All characters have disengaged + self.obj.msg_contents("All fighters have disengaged! Combat is over!") + self.stop() # Stop this script and end combat. + return + + # Check to see if only one character is left standing. If so, end combat. + defeated_characters = 0 + for fighter in self.db.fighters: + if fighter.db.HP == 0: + defeated_characters += 1 # Add 1 for every fighter with 0 HP left (defeated) + if defeated_characters == (len(self.db.fighters) - 1): # If only one character isn't defeated + for fighter in self.db.fighters: + if fighter.db.HP != 0: + LastStanding = fighter # Pick the one fighter left with HP remaining + self.obj.msg_contents("Only %s remains! Combat is over!" % LastStanding) + self.stop() # Stop this script and end combat. + return + + # Cycle to the next turn. + currentchar = self.db.fighters[self.db.turn] + self.db.turn += 1 # Go to the next in the turn order. + if self.db.turn > len(self.db.fighters) - 1: + self.db.turn = 0 # Go back to the first in the turn order once you reach the end. + newchar = self.db.fighters[self.db.turn] # Note the new character + self.db.timer = TURN_TIMEOUT + self.time_until_next_repeat() # Reset the timer. + self.db.timeout_warning_given = False # Reset the timeout warning. + self.obj.msg_contents("%s's turn ends - %s's turn begins!" % (currentchar, newchar)) + self.start_turn(newchar) # Start the new character's turn. + + def turn_end_check(self, character): + """ + Tests to see if a character's turn is over, and cycles to the next turn if it is. + + Args: + character (obj): Character to test for end of turn + """ + if not character.db.combat_actionsleft: # Character has no actions remaining + self.next_turn() + return + + def join_fight(self, character): + """ + Adds a new character to a fight already in progress. + + Args: + character (obj): Character to be added to the fight. + """ + # Inserts the fighter to the turn order, right behind whoever's turn it currently is. + self.db.fighters.insert(self.db.turn, character) + # Tick the turn counter forward one to compensate. + self.db.turn += 1 + # Initialize the character like you do at the start. + self.initialize_for_combat(character) + + +""" +---------------------------------------------------------------------------- +COMMANDS START HERE +---------------------------------------------------------------------------- +""" + + +class CmdFight(Command): + """ + Starts a fight with everyone in the same room as you. + + Usage: + fight + + When you start a fight, everyone in the room who is able to + fight is added to combat, and a turn order is randomly rolled. + When it's your turn, you can attack other characters. + """ + key = "fight" + help_category = "combat" + + def func(self): + """ + This performs the actual command. + """ + here = self.caller.location + fighters = [] + + if not self.caller.db.hp: # If you don't have any hp + self.caller.msg("You can't start a fight if you've been defeated!") + return + if is_in_combat(self.caller): # Already in a fight + self.caller.msg("You're already in a fight!") + return + for thing in here.contents: # Test everything in the room to add it to the fight. + if thing.db.HP: # If the object has HP... + fighters.append(thing) # ...then add it to the fight. + if len(fighters) <= 1: # If you're the only able fighter in the room + self.caller.msg("There's nobody here to fight!") + return + if here.db.combat_turnhandler: # If there's already a fight going on... + here.msg_contents("%s joins the fight!" % self.caller) + here.db.combat_turnhandler.join_fight(self.caller) # Join the fight! + return + here.msg_contents("%s starts a fight!" % self.caller) + # Add a turn handler script to the room, which starts combat. + here.scripts.add("contrib.turnbattle.tb_basic.TBBasicTurnHandler") + # Remember you'll have to change the path to the script if you copy this code to your own modules! + + +class CmdAttack(Command): + """ + Attacks another character. + + Usage: + attack + + When in a fight, you may attack another character. The attack has + a chance to hit, and if successful, will deal damage. + """ + + key = "attack" + help_category = "combat" + + def func(self): + "This performs the actual command." + "Set the attacker to the caller and the defender to the target." + + if not is_in_combat(self.caller): # If not in combat, can't attack. + self.caller.msg("You can only do that in combat. (see: help fight)") + return + + if not is_turn(self.caller): # If it's not your turn, can't attack. + self.caller.msg("You can only do that on your turn.") + return + + if not self.caller.db.hp: # Can't attack if you have no HP. + self.caller.msg("You can't attack, you've been defeated.") + return + + attacker = self.caller + defender = self.caller.search(self.args) + + if not defender: # No valid target given. + return + + if not defender.db.hp: # Target object has no HP left or to begin with + self.caller.msg("You can't fight that!") + return + + if attacker == defender: # Target and attacker are the same + self.caller.msg("You can't attack yourself!") + return + + "If everything checks out, call the attack resolving function." + resolve_attack(attacker, defender) + spend_action(self.caller, 1, action_name="attack") # Use up one action. + + +class CmdPass(Command): + """ + Passes on your turn. + + Usage: + pass + + When in a fight, you can use this command to end your turn early, even + if there are still any actions you can take. + """ + + key = "pass" + aliases = ["wait", "hold"] + help_category = "combat" + + def func(self): + """ + This performs the actual command. + """ + if not is_in_combat(self.caller): # Can only pass a turn in combat. + self.caller.msg("You can only do that in combat. (see: help fight)") + return + + if not is_turn(self.caller): # Can only pass if it's your turn. + self.caller.msg("You can only do that on your turn.") + return + + self.caller.location.msg_contents("%s takes no further action, passing the turn." % self.caller) + spend_action(self.caller, 'all', action_name="pass") # Spend all remaining actions. + + +class CmdDisengage(Command): + """ + Passes your turn and attempts to end combat. + + Usage: + disengage + + Ends your turn early and signals that you're trying to end + the fight. If all participants in a fight disengage, the + fight ends. + """ + + key = "disengage" + aliases = ["spare"] + help_category = "combat" + + def func(self): + """ + This performs the actual command. + """ + if not is_in_combat(self.caller): # If you're not in combat + self.caller.msg("You can only do that in combat. (see: help fight)") + return + + if not is_turn(self.caller): # If it's not your turn + self.caller.msg("You can only do that on your turn.") + return + + self.caller.location.msg_contents("%s disengages, ready to stop fighting." % self.caller) + spend_action(self.caller, 'all', action_name="disengage") # Spend all remaining actions. + """ + The action_name kwarg sets the character's last action to "disengage", which is checked by + the turn handler script to see if all fighters have disengaged. + """ + + +class CmdRest(Command): + """ + Recovers damage. + + Usage: + rest + + Resting recovers your HP to its maximum, but you can only + rest if you're not in a fight. + """ + + key = "rest" + help_category = "combat" + + def func(self): + "This performs the actual command." + + if is_in_combat(self.caller): # If you're in combat + self.caller.msg("You can't rest while you're in combat.") + return + + self.caller.db.hp = self.caller.db.max_hp # Set current HP to maximum + self.caller.location.msg_contents("%s rests to recover HP." % self.caller) + """ + You'll probably want to replace this with your own system for recovering HP. + """ + + +class CmdCombatHelp(CmdHelp): + """ + View help or a list of topics + + Usage: + help + help list + help all + + This will search for help on commands and other + topics related to the game. + """ + # Just like the default help command, but will give quick + # tips on combat when used in a fight with no arguments. + + def func(self): + if is_in_combat(self.caller) and not self.args: # In combat and entered 'help' alone + self.caller.msg("Available combat commands:|/" + + "|wAttack:|n Attack a target, attempting to deal damage.|/" + + "|wPass:|n Pass your turn without further action.|/" + + "|wDisengage:|n End your turn and attempt to end combat.|/") + else: + super(CmdCombatHelp, self).func() # Call the default help command + + +class BattleCmdSet(default_cmds.CharacterCmdSet): + """ + This command set includes all the commmands used in the battle system. + """ + key = "DefaultCharacter" + + def at_cmdset_creation(self): + """ + Populates the cmdset + """ + self.add(CmdFight()) + self.add(CmdAttack()) + self.add(CmdRest()) + self.add(CmdPass()) + self.add(CmdDisengage()) + self.add(CmdCombatHelp()) From 57cfb8e025e80738c2725f9ad255725728c51e75 Mon Sep 17 00:00:00 2001 From: BattleJenkins Date: Wed, 15 Nov 2017 23:12:25 -0800 Subject: [PATCH 008/208] Added 'use' command, item functions, example items --- evennia/contrib/turnbattle/tb_items.py | 268 ++++++++++++++++++++++--- 1 file changed, 245 insertions(+), 23 deletions(-) diff --git a/evennia/contrib/turnbattle/tb_items.py b/evennia/contrib/turnbattle/tb_items.py index d4883e4c99..70cedd7fda 100644 --- a/evennia/contrib/turnbattle/tb_items.py +++ b/evennia/contrib/turnbattle/tb_items.py @@ -1,41 +1,48 @@ """ -Simple turn-based combat system +Simple turn-based combat system with items and status effects Contrib - Tim Ashley Jenkins 2017 -This is a framework for a simple turn-based combat system, similar -to those used in D&D-style tabletop role playing games. It allows -any character to start a fight in a room, at which point initiative -is rolled and a turn order is established. Each participant in combat -has a limited time to decide their action for that turn (30 seconds by -default), and combat progresses through the turn order, looping through -the participants until the fight ends. +This is a version of the 'turnbattle' combat system that includes status +effects and usable items, which can instill these status effects, cure +them, or do just about anything else. -Only simple rolls for attacking are implemented here, but this system -is easily extensible and can be used as the foundation for implementing -the rules from your turn-based tabletop game of choice or making your -own battle system. +Status effects are stored on characters as a dictionary, where the key +is the name of the status effect and the value is a list of two items: +an integer representing the number of turns left until the status runs +out, and the character upon whose turn the condition timer is ticked +down. Unlike most combat-related attributes, conditions aren't wiped +once combat ends - if out of combat, they tick down in real time +instead. -To install and test, import this module's TBBasicCharacter object into +Items aren't given any sort of special typeclass - instead, whether or +not an object counts as an item is determined by its attributes. To make +an object into an item, it must have the attribute 'item_on_use', with +the value given as a callable - this is the function that will be called +when an item is used. Other properties of the item, such as how many +uses it has, whether it's destroyed when its uses are depleted, and such +can be specified on the item as well, but they are optional. + +To install and test, import this module's TBItemsCharacter object into your game's character.py module: - from evennia.contrib.turnbattle.tb_basic import TBBasicCharacter + from evennia.contrib.turnbattle.tb_items import TBItemsCharacter -And change your game's character typeclass to inherit from TBBasicCharacter +And change your game's character typeclass to inherit from TBItemsCharacter instead of the default: - class Character(TBBasicCharacter): + class Character(TBItemsCharacter): Next, import this module into your default_cmdsets.py module: - from evennia.contrib.turnbattle import tb_basic + from evennia.contrib.turnbattle import tb_items And add the battle command set to your default command set: # # any commands you add below will overload the default ones. # - self.add(tb_basic.BattleCmdSet()) + self.add(tb_items.BattleCmdSet()) This module is meant to be heavily expanded on, so you may want to copy it to your game's 'world' folder and modify it there rather than importing it @@ -44,7 +51,9 @@ in your game and using it as-is. from random import randint from evennia import DefaultCharacter, Command, default_cmds, DefaultScript +from evennia.commands.default.muxcommand import MuxCommand from evennia.commands.default.help import CmdHelp +from evennia.utils.spawner import spawn """ ---------------------------------------------------------------------------- @@ -190,7 +199,7 @@ def at_defeat(defeated): """ defeated.location.msg_contents("%s has been defeated!" % defeated) -def resolve_attack(attacker, defender, attack_value=None, defense_value=None): +def resolve_attack(attacker, defender, attack_value=None, defense_value=None, damage_value=None): """ Resolves an attack and outputs the result. @@ -213,7 +222,8 @@ def resolve_attack(attacker, defender, attack_value=None, defense_value=None): if attack_value < defense_value: attacker.location.msg_contents("%s's attack misses %s!" % (attacker, defender)) else: - damage_value = get_damage(attacker, defender) # Calculate damage value. + if not damage_value: + damage_value = get_damage(attacker, defender) # Calculate damage value. # Announce damage dealt and apply damage. attacker.location.msg_contents("%s hits %s for %i damage!" % (attacker, defender, damage_value)) apply_damage(defender, damage_value) @@ -287,6 +297,33 @@ def spend_action(character, actions, action_name=None): character.db.combat_actionsleft = 0 # Can't have fewer than 0 actions character.db.combat_turnhandler.turn_end_check(character) # Signal potential end of turn. +def spend_item_use(item): + """ + Spends one use on an item with limited uses. If item.db.item_consumable + is 'True', the item is destroyed if it runs out of uses - if it's a string + instead of 'True', it will also spawn a new object as residue, using the + value of item.db.item_consumable as the name of the prototype to spawn. + + + """ + if item.db.item_uses: + item.db.item_uses -= 1 # Spend one use + if item.db.item_uses > 0: # Has uses remaining + # Inform th eplayer + self.caller.msg("%s has %i uses remaining." % (item.key.capitalize(), item.db.item_uses)) + else: # All uses spent + if not item.db.item_consumable: + # If not consumable, just inform the player that the uses are gone + self.caller.msg("%s has no uses remaining." % item.key.capitalize()) + else: # If consumable + if item.db.item_consumable == True: # If the value is 'True', just destroy the item + self.caller.msg("%s has been consumed." % item.key.capitalize()) + item.delete() # Delete the spent item + else: # If a string, use value of item_consumable to spawn an object in its place + residue = spawn({"prototype":item.db.item_consumable})[0] # Spawn the residue + residue.location = item.location # Move the residue to the same place as the item + self.caller.msg("After using %s, you are left with %s." % (item, residue)) + item.delete() # Delete the spent item """ ---------------------------------------------------------------------------- @@ -295,7 +332,7 @@ CHARACTER TYPECLASS """ -class TBBasicCharacter(DefaultCharacter): +class TBItemsCharacter(DefaultCharacter): """ A character able to participate in turn-based combat. Has attributes for current and maximum HP, and access to combat commands. @@ -348,7 +385,7 @@ SCRIPTS START HERE """ -class TBBasicTurnHandler(DefaultScript): +class TBItemsTurnHandler(DefaultScript): """ This is the script that handles the progression of combat through turns. On creation (when a fight is started) it adds all combat-ready characters @@ -563,7 +600,7 @@ class CmdFight(Command): return here.msg_contents("%s starts a fight!" % self.caller) # Add a turn handler script to the room, which starts combat. - here.scripts.add("contrib.turnbattle.tb_basic.TBBasicTurnHandler") + here.scripts.add("contrib.turnbattle.tb_items.TBItemsTurnHandler") # Remember you'll have to change the path to the script if you copy this code to your own modules! @@ -736,6 +773,73 @@ class CmdCombatHelp(CmdHelp): super(CmdCombatHelp, self).func() # Call the default help command +class CmdUse(MuxCommand): + """ + Use an item. + + Usage: + use [= target] + + Items: you just GOTTA use them. + """ + + key = "use" + help_category = "combat" + + def func(self): + """ + This performs the actual command. + """ + item = self.caller.search(self.lhs, candidates=self.caller.contents) + if not item: + return + + target = None + if self.rhs: + target = self.caller.search(self.rhs) + if not target: + return + + if is_in_combat(self.caller): + if not is_turn(self.caller): + self.caller.msg("You can only use items on your turn.") + return + + if not item.db.item_func: # Object has no item_func, not usable + self.caller.msg("'%s' is not a usable item." % item.key.capitalize()) + return + + if item.attributes.has("item_uses"): # Item has limited uses + if item.db.item_uses <= 0: # Limited uses are spent + self.caller.msg("'%s' has no uses remaining." % item.key.capitalize()) + return + + kwargs = {} + if item.db.item_kwargs: + kwargs = item.db.item_kwargs # Set kwargs to pass to item_func + + # Match item_func string to function + try: + item_func = ITEMFUNCS[item.db.item_func] + except KeyError: + self.caller.msg("ERROR: %s not defined in ITEMFUNCS" % item.db.item_func) + return + + # Call the item function - abort if it returns False, indicating an error. + # Regardless of what the function returns (if anything), it's still executed. + if item_func(item, self.caller, target, **kwargs) == False: + return + + # If we haven't returned yet, we assume the item was used successfully. + + # Spend one use if item has limited uses + spend_item_use(item) + + # Spend an action if in combat + if is_in_combat(self.caller): + spend_action(self.caller, 1, action_name="item") + + class BattleCmdSet(default_cmds.CharacterCmdSet): """ This command set includes all the commmands used in the battle system. @@ -752,3 +856,121 @@ class BattleCmdSet(default_cmds.CharacterCmdSet): self.add(CmdPass()) self.add(CmdDisengage()) self.add(CmdCombatHelp()) + self.add(CmdUse()) + +""" +ITEM FUNCTIONS START HERE +""" + +def itemfunc_heal(item, user, target, **kwargs): + """ + Item function that heals HP. + """ + if not target: + target = user # Target user if none specified + + if not target.attributes.has("max_hp"): # Has no HP to speak of + user.msg("You can't use %s on that." % item) + return False + + if target.db.hp >= target.db.max_hp: + user.msg("%s is already at full health." % target) + return False + + min_healing = 20 + max_healing = 40 + + # Retrieve healing range from kwargs, if present + if "healing_range" in kwargs: + min_healing = kwargs["healing_range"][0] + max_healing = kwargs["healing_range"][1] + + to_heal = randint(min_healing, max_healing) # Restore 20 to 40 hp + if target.db.hp + to_heal > target.db.max_hp: + to_heal = target.db.max_hp - target.db.hp # Cap healing to max HP + target.db.hp += to_heal + + user.location.msg_contents("%s uses %s! %s regains %i HP!" % (user, item, target, to_heal)) + +def itemfunc_attack(item, user, target, **kwargs): + """ + Item function that attacks a target. + """ + if not is_in_combat(user): + user.msg("You can only use that in combat.") + return False + + if not target: + user.msg("You have to specify a target to use %s! (use = )" % item) + return False + + if target == user: + user.msg("You can't attack yourself!") + return False + + if not target.db.hp: # Has no HP + user.msg("You can't use %s on that." % item) + return False + + min_damage = 20 + max_damage = 40 + accuracy = 0 + + # Retrieve values from kwargs, if present + if "damage_range" in kwargs: + min_damage = kwargs["damage_range"][0] + max_damage = kwargs["damage_range"][1] + if "accuracy" in kwargs: + accuracy = kwargs["accuracy"] + + # Roll attack and damage + attack_value = randint(1, 100) + accuracy + damage_value = randint(min_damage, max_damage) + + user.location.msg_contents("%s attacks %s with %s!" % (user, target, item)) + resolve_attack(user, target, attack_value=attack_value, damage_value=damage_value) + +# Match strings to item functions here. We can't store callables on +# prototypes, so we store a string instead, matching that string to +# a callable in this dictionary. +ITEMFUNCS = { + "heal":itemfunc_heal, + "attack":itemfunc_attack +} + +""" +ITEM PROTOTYPES START HERE +""" + +MEDKIT = { + "key" : "a medical kit", + "aliases" : ["medkit"], + "desc" : "A standard medical kit. It can be used a few times to heal wounds.", + "item_func" : "heal", + "item_uses" : 3, + "item_consumable" : True, + "item_kwargs" : {"healing_range":(15, 25)} +} + +GLASS_BOTTLE = { + "key" : "a glass bottle", + "desc" : "An empty glass bottle." +} + +HEALTH_POTION = { + "key" : "a health potion", + "desc" : "A glass bottle full of a mystical potion that heals wounds when used.", + "item_func" : "heal", + "item_uses" : 1, + "item_consumable" : "GLASS_BOTTLE", + "item_kwargs" : {"healing_range":(35, 50)} +} + +BOMB = { + "key" : "a rotund bomb", + "desc" : "A large black sphere with a fuse at the end. Can be used on enemies in combat.", + "item_func" : "attack", + "item_uses" : 1, + "item_consumable" : True, + "item_kwargs" : {"damage_range":(25, 40), "accuracy":25} +} \ No newline at end of file From fd5f6ba981a0bf42d9cadb9e8b5d76e933ab9cf2 Mon Sep 17 00:00:00 2001 From: BattleJenkins Date: Thu, 16 Nov 2017 00:15:20 -0800 Subject: [PATCH 009/208] Proper implementation of spend_item_use() --- evennia/contrib/turnbattle/tb_items.py | 36 ++++++++++++++------------ 1 file changed, 20 insertions(+), 16 deletions(-) diff --git a/evennia/contrib/turnbattle/tb_items.py b/evennia/contrib/turnbattle/tb_items.py index 70cedd7fda..b99fba599d 100644 --- a/evennia/contrib/turnbattle/tb_items.py +++ b/evennia/contrib/turnbattle/tb_items.py @@ -17,7 +17,7 @@ instead. Items aren't given any sort of special typeclass - instead, whether or not an object counts as an item is determined by its attributes. To make -an object into an item, it must have the attribute 'item_on_use', with +an object into an item, it must have the attribute 'item_func', with the value given as a callable - this is the function that will be called when an item is used. Other properties of the item, such as how many uses it has, whether it's destroyed when its uses are depleted, and such @@ -297,32 +297,30 @@ def spend_action(character, actions, action_name=None): character.db.combat_actionsleft = 0 # Can't have fewer than 0 actions character.db.combat_turnhandler.turn_end_check(character) # Signal potential end of turn. -def spend_item_use(item): +def spend_item_use(item, user): """ Spends one use on an item with limited uses. If item.db.item_consumable is 'True', the item is destroyed if it runs out of uses - if it's a string instead of 'True', it will also spawn a new object as residue, using the value of item.db.item_consumable as the name of the prototype to spawn. - - """ if item.db.item_uses: item.db.item_uses -= 1 # Spend one use if item.db.item_uses > 0: # Has uses remaining - # Inform th eplayer - self.caller.msg("%s has %i uses remaining." % (item.key.capitalize(), item.db.item_uses)) + # Inform the player + user.msg("%s has %i uses remaining." % (item.key.capitalize(), item.db.item_uses)) else: # All uses spent if not item.db.item_consumable: # If not consumable, just inform the player that the uses are gone - self.caller.msg("%s has no uses remaining." % item.key.capitalize()) + user.msg("%s has no uses remaining." % item.key.capitalize()) else: # If consumable if item.db.item_consumable == True: # If the value is 'True', just destroy the item - self.caller.msg("%s has been consumed." % item.key.capitalize()) + user.msg("%s has been consumed." % item.key.capitalize()) item.delete() # Delete the spent item else: # If a string, use value of item_consumable to spawn an object in its place residue = spawn({"prototype":item.db.item_consumable})[0] # Spawn the residue residue.location = item.location # Move the residue to the same place as the item - self.caller.msg("After using %s, you are left with %s." % (item, residue)) + user.msg("After using %s, you are left with %s." % (item, residue)) item.delete() # Delete the spent item """ @@ -790,16 +788,19 @@ class CmdUse(MuxCommand): """ This performs the actual command. """ + # Search for item item = self.caller.search(self.lhs, candidates=self.caller.contents) if not item: return + # Search for target, if any is given target = None if self.rhs: target = self.caller.search(self.rhs) if not target: return - + + # If in combat, can only use items on your turn if is_in_combat(self.caller): if not is_turn(self.caller): self.caller.msg("You can only use items on your turn.") @@ -814,9 +815,10 @@ class CmdUse(MuxCommand): self.caller.msg("'%s' has no uses remaining." % item.key.capitalize()) return + # Set kwargs to pass to item_func kwargs = {} if item.db.item_kwargs: - kwargs = item.db.item_kwargs # Set kwargs to pass to item_func + kwargs = item.db.item_kwargs # Match item_func string to function try: @@ -826,14 +828,14 @@ class CmdUse(MuxCommand): return # Call the item function - abort if it returns False, indicating an error. + # This performs the actual action of using the item. # Regardless of what the function returns (if anything), it's still executed. if item_func(item, self.caller, target, **kwargs) == False: return # If we haven't returned yet, we assume the item was used successfully. - # Spend one use if item has limited uses - spend_item_use(item) + spend_item_use(item, self.caller) # Spend an action if in combat if is_in_combat(self.caller): @@ -871,7 +873,7 @@ def itemfunc_heal(item, user, target, **kwargs): if not target.attributes.has("max_hp"): # Has no HP to speak of user.msg("You can't use %s on that." % item) - return False + return False # Returning false aborts the item use if target.db.hp >= target.db.max_hp: user.msg("%s is already at full health." % target) @@ -898,7 +900,7 @@ def itemfunc_attack(item, user, target, **kwargs): """ if not is_in_combat(user): user.msg("You can only use that in combat.") - return False + return False # Returning false aborts the item use if not target: user.msg("You have to specify a target to use %s! (use = )" % item) @@ -906,7 +908,7 @@ def itemfunc_attack(item, user, target, **kwargs): if target == user: user.msg("You can't attack yourself!") - return False + return False if not target.db.hp: # Has no HP user.msg("You can't use %s on that." % item) @@ -940,6 +942,8 @@ ITEMFUNCS = { """ ITEM PROTOTYPES START HERE + +Copy these to your game's /world/prototypes.py module! """ MEDKIT = { From 9f8c92e4d4fefd8615bf55a1b6314e0b7f4e6a45 Mon Sep 17 00:00:00 2001 From: BattleJenkins Date: Fri, 17 Nov 2017 16:46:27 -0800 Subject: [PATCH 010/208] Move some item logic from CmdUse to new func use_item --- evennia/contrib/turnbattle/tb_items.py | 57 +++++++++++++++----------- 1 file changed, 32 insertions(+), 25 deletions(-) diff --git a/evennia/contrib/turnbattle/tb_items.py b/evennia/contrib/turnbattle/tb_items.py index b99fba599d..94ab4bacea 100644 --- a/evennia/contrib/turnbattle/tb_items.py +++ b/evennia/contrib/turnbattle/tb_items.py @@ -322,6 +322,36 @@ def spend_item_use(item, user): residue.location = item.location # Move the residue to the same place as the item user.msg("After using %s, you are left with %s." % (item, residue)) item.delete() # Delete the spent item + +def use_item(user, item, target): + """ + Performs the action of using an item. + """ + # Set kwargs to pass to item_func + kwargs = {} + if item.db.item_kwargs: + kwargs = item.db.item_kwargs + + # Match item_func string to function + try: + item_func = ITEMFUNCS[item.db.item_func] + except KeyError: + user.msg("ERROR: %s not defined in ITEMFUNCS" % item.db.item_func) + return + + # Call the item function - abort if it returns False, indicating an error. + # This performs the actual action of using the item. + # Regardless of what the function returns (if anything), it's still executed. + if item_func(item, user, target, **kwargs) == False: + return + + # If we haven't returned yet, we assume the item was used successfully. + # Spend one use if item has limited uses + spend_item_use(item, user) + + # Spend an action if in combat + if is_in_combat(user): + spend_action(user, 1, action_name="item") """ ---------------------------------------------------------------------------- @@ -815,31 +845,8 @@ class CmdUse(MuxCommand): self.caller.msg("'%s' has no uses remaining." % item.key.capitalize()) return - # Set kwargs to pass to item_func - kwargs = {} - if item.db.item_kwargs: - kwargs = item.db.item_kwargs - - # Match item_func string to function - try: - item_func = ITEMFUNCS[item.db.item_func] - except KeyError: - self.caller.msg("ERROR: %s not defined in ITEMFUNCS" % item.db.item_func) - return - - # Call the item function - abort if it returns False, indicating an error. - # This performs the actual action of using the item. - # Regardless of what the function returns (if anything), it's still executed. - if item_func(item, self.caller, target, **kwargs) == False: - return - - # If we haven't returned yet, we assume the item was used successfully. - # Spend one use if item has limited uses - spend_item_use(item, self.caller) - - # Spend an action if in combat - if is_in_combat(self.caller): - spend_action(self.caller, 1, action_name="item") + # If everything checks out, call the use_item function + use_item(self.caller, item, target) class BattleCmdSet(default_cmds.CharacterCmdSet): From c3ffa7b9060a7215a202f2dfc77ac2c2b5234256 Mon Sep 17 00:00:00 2001 From: BattleJenkins Date: Fri, 17 Nov 2017 21:17:35 -0800 Subject: [PATCH 011/208] Start porting in condition code from coolbattles --- evennia/contrib/turnbattle/tb_items.py | 78 +++++++++++++++++++------- 1 file changed, 58 insertions(+), 20 deletions(-) diff --git a/evennia/contrib/turnbattle/tb_items.py b/evennia/contrib/turnbattle/tb_items.py index 94ab4bacea..8f33e717b1 100644 --- a/evennia/contrib/turnbattle/tb_items.py +++ b/evennia/contrib/turnbattle/tb_items.py @@ -304,24 +304,28 @@ def spend_item_use(item, user): instead of 'True', it will also spawn a new object as residue, using the value of item.db.item_consumable as the name of the prototype to spawn. """ - if item.db.item_uses: - item.db.item_uses -= 1 # Spend one use - if item.db.item_uses > 0: # Has uses remaining - # Inform the player - user.msg("%s has %i uses remaining." % (item.key.capitalize(), item.db.item_uses)) - else: # All uses spent - if not item.db.item_consumable: - # If not consumable, just inform the player that the uses are gone - user.msg("%s has no uses remaining." % item.key.capitalize()) - else: # If consumable - if item.db.item_consumable == True: # If the value is 'True', just destroy the item - user.msg("%s has been consumed." % item.key.capitalize()) - item.delete() # Delete the spent item - else: # If a string, use value of item_consumable to spawn an object in its place - residue = spawn({"prototype":item.db.item_consumable})[0] # Spawn the residue - residue.location = item.location # Move the residue to the same place as the item - user.msg("After using %s, you are left with %s." % (item, residue)) - item.delete() # Delete the spent item + item.db.item_uses -= 1 # Spend one use + + if item.db.item_uses > 0: # Has uses remaining + # Inform the player + user.msg("%s has %i uses remaining." % (item.key.capitalize(), item.db.item_uses)) + + else: # All uses spent + + if not item.db.item_consumable: # Item isn't consumable + # Just inform the player that the uses are gone + user.msg("%s has no uses remaining." % item.key.capitalize()) + + else: # If item is consumable + if item.db.item_consumable == True: # If the value is 'True', just destroy the item + user.msg("%s has been consumed." % item.key.capitalize()) + item.delete() # Delete the spent item + + else: # If a string, use value of item_consumable to spawn an object in its place + residue = spawn({"prototype":item.db.item_consumable})[0] # Spawn the residue + residue.location = item.location # Move the residue to the same place as the item + user.msg("After using %s, you are left with %s." % (item, residue)) + item.delete() # Delete the spent item def use_item(user, item, target): """ @@ -347,11 +351,38 @@ def use_item(user, item, target): # If we haven't returned yet, we assume the item was used successfully. # Spend one use if item has limited uses - spend_item_use(item, user) + if item.db.item_uses: + spend_item_use(item, user) # Spend an action if in combat if is_in_combat(user): spend_action(user, 1, action_name="item") + +def condition_tickdown(character, turnchar): + """ + Ticks down the duration of conditions on a character at the end of a given character's turn. + """ + + for key in character.db.conditions: + # The first value is the remaining turns - the second value is whose turn to count down on. + condition_duration = character.db.conditions[key][0] + condition_turnchar = character.db.conditions[key][1] + # Count down if the given turn character matches the condition's turn character. + if condition_turnchar == turnchar: + character.db.conditions[key][0] -= 1 + if character.db.conditions[key][0] <= 0: + # If the duration is brought down to 0, remove the condition and inform everyone. + character.location.msg_contents("%s no longer has the '%s' condition." % (str(character), str(key))) + del character.db.conditions[key] + +def add_condition(character, turnchar, condition, duration): + """ + Adds a condition to a fighter. + """ + # The first value is the remaining turns - the second value is whose turn to count down on. + character.db.conditions.update({condition:[duration, turnchar]}) + # Tell everyone! + character.location.msg_contents("%s gains the '%s' condition." % (character, condition)) """ ---------------------------------------------------------------------------- @@ -373,6 +404,7 @@ class TBItemsCharacter(DefaultCharacter): """ self.db.max_hp = 100 # Set maximum HP to 100 self.db.hp = self.db.max_hp # Set current HP to maximum + self.db.conditions = {} # Set empty dict for conditions """ Adds attributes for a character's current and maximum HP. We're just going to set this value at '100' by default. @@ -550,6 +582,11 @@ class TBItemsTurnHandler(DefaultScript): self.db.turn += 1 # Go to the next in the turn order. if self.db.turn > len(self.db.fighters) - 1: self.db.turn = 0 # Go back to the first in the turn order once you reach the end. + + # Count down condition timers. + for fighter in self.db.fighters: + condition_tickdown(fighter, newchar) + newchar = self.db.fighters[self.db.turn] # Note the new character self.db.timer = TURN_TIMEOUT + self.time_until_next_repeat() # Reset the timer. self.db.timeout_warning_given = False # Reset the timeout warning. @@ -796,7 +833,8 @@ class CmdCombatHelp(CmdHelp): self.caller.msg("Available combat commands:|/" + "|wAttack:|n Attack a target, attempting to deal damage.|/" + "|wPass:|n Pass your turn without further action.|/" + - "|wDisengage:|n End your turn and attempt to end combat.|/") + "|wDisengage:|n End your turn and attempt to end combat.|/" + + "|wUse:|n Use an item you're carrying.") else: super(CmdCombatHelp, self).func() # Call the default help command From b54a1679895660304fbcee095204f0ad1cc290c0 Mon Sep 17 00:00:00 2001 From: BattleJenkins Date: Fri, 17 Nov 2017 21:19:02 -0800 Subject: [PATCH 012/208] Fix weird spacing in use_item() --- evennia/contrib/turnbattle/tb_items.py | 56 +++++++++++++------------- 1 file changed, 28 insertions(+), 28 deletions(-) diff --git a/evennia/contrib/turnbattle/tb_items.py b/evennia/contrib/turnbattle/tb_items.py index 8f33e717b1..ea3eb89fc6 100644 --- a/evennia/contrib/turnbattle/tb_items.py +++ b/evennia/contrib/turnbattle/tb_items.py @@ -328,35 +328,35 @@ def spend_item_use(item, user): item.delete() # Delete the spent item def use_item(user, item, target): - """ - Performs the action of using an item. - """ - # Set kwargs to pass to item_func - kwargs = {} - if item.db.item_kwargs: - kwargs = item.db.item_kwargs - - # Match item_func string to function - try: - item_func = ITEMFUNCS[item.db.item_func] - except KeyError: - user.msg("ERROR: %s not defined in ITEMFUNCS" % item.db.item_func) - return + """ + Performs the action of using an item. + """ + # Set kwargs to pass to item_func + kwargs = {} + if item.db.item_kwargs: + kwargs = item.db.item_kwargs - # Call the item function - abort if it returns False, indicating an error. - # This performs the actual action of using the item. - # Regardless of what the function returns (if anything), it's still executed. - if item_func(item, user, target, **kwargs) == False: - return - - # If we haven't returned yet, we assume the item was used successfully. - # Spend one use if item has limited uses - if item.db.item_uses: - spend_item_use(item, user) - - # Spend an action if in combat - if is_in_combat(user): - spend_action(user, 1, action_name="item") + # Match item_func string to function + try: + item_func = ITEMFUNCS[item.db.item_func] + except KeyError: + user.msg("ERROR: %s not defined in ITEMFUNCS" % item.db.item_func) + return + + # Call the item function - abort if it returns False, indicating an error. + # This performs the actual action of using the item. + # Regardless of what the function returns (if anything), it's still executed. + if item_func(item, user, target, **kwargs) == False: + return + + # If we haven't returned yet, we assume the item was used successfully. + # Spend one use if item has limited uses + if item.db.item_uses: + spend_item_use(item, user) + + # Spend an action if in combat + if is_in_combat(user): + spend_action(user, 1, action_name="item") def condition_tickdown(character, turnchar): """ From d9d4a8292ff49850218ea854649f0040305b6d46 Mon Sep 17 00:00:00 2001 From: BattleJenkins Date: Sat, 18 Nov 2017 13:22:03 -0800 Subject: [PATCH 013/208] Added functional condition, TickerHandler countdown --- evennia/contrib/turnbattle/tb_items.py | 92 +++++++++++++++++++++++--- 1 file changed, 84 insertions(+), 8 deletions(-) diff --git a/evennia/contrib/turnbattle/tb_items.py b/evennia/contrib/turnbattle/tb_items.py index ea3eb89fc6..8457a13bd6 100644 --- a/evennia/contrib/turnbattle/tb_items.py +++ b/evennia/contrib/turnbattle/tb_items.py @@ -54,6 +54,7 @@ from evennia import DefaultCharacter, Command, default_cmds, DefaultScript from evennia.commands.default.muxcommand import MuxCommand from evennia.commands.default.help import CmdHelp from evennia.utils.spawner import spawn +from evennia import TICKER_HANDLER as tickerhandler """ ---------------------------------------------------------------------------- @@ -360,7 +361,7 @@ def use_item(user, item, target): def condition_tickdown(character, turnchar): """ - Ticks down the duration of conditions on a character at the end of a given character's turn. + Ticks down the duration of conditions on a character at the start of a given character's turn. """ for key in character.db.conditions: @@ -405,6 +406,8 @@ class TBItemsCharacter(DefaultCharacter): self.db.max_hp = 100 # Set maximum HP to 100 self.db.hp = self.db.max_hp # Set current HP to maximum self.db.conditions = {} # Set empty dict for conditions + # Subscribe character to the ticker handler + tickerhandler.add(30, self.at_update) """ Adds attributes for a character's current and maximum HP. We're just going to set this value at '100' by default. @@ -437,6 +440,42 @@ class TBItemsCharacter(DefaultCharacter): self.msg("You can't move, you've been defeated!") return False return True + + def at_turn_start(self): + """ + Hook called at the beginning of this character's turn in combat. + """ + # Prompt the character for their turn and give some information. + self.msg("|wIt's your turn! You have %i HP remaining.|n" % self.db.hp) + + # Apply conditions that fire at the start of each turn. + self.apply_turn_conditions() + + def apply_turn_conditions(self): + """ + Applies the effect of conditions that occur at the start of each + turn in combat, or every 30 seconds out of combat. + """ + if "Regeneration" in self.db.conditions: + to_heal = randint(4, 8) # Restore 4 to 8 HP + if self.db.hp + to_heal > self.db.max_hp: + to_heal = self.db.max_hp - self.db.hp # Cap healing to max HP + self.db.hp += to_heal + self.location.msg_contents("%s regains %i HP from Regeneration." % (self, to_heal)) + + def at_update(self): + """ + Fires every 30 seconds. + """ + # Change all conditions to update on character's turn. + for key in self.db.conditions: + self.db.conditions[key][1] = self + if not is_in_combat(self): # Not in combat + # Apply conditions that fire every turn + self.apply_turn_conditions() + # Tick down condition durations + condition_tickdown(self, self) + """ ---------------------------------------------------------------------------- @@ -546,8 +585,8 @@ class TBItemsTurnHandler(DefaultScript): something similar. """ character.db.combat_actionsleft = ACTIONS_PER_TURN # Replenish actions - # Prompt the character for their turn and give some information. - character.msg("|wIt's your turn! You have %i HP remaining.|n" % character.db.hp) + # Call character's at_turn_start() hook. + character.at_turn_start() def next_turn(self): """ @@ -582,16 +621,17 @@ class TBItemsTurnHandler(DefaultScript): self.db.turn += 1 # Go to the next in the turn order. if self.db.turn > len(self.db.fighters) - 1: self.db.turn = 0 # Go back to the first in the turn order once you reach the end. - - # Count down condition timers. - for fighter in self.db.fighters: - condition_tickdown(fighter, newchar) newchar = self.db.fighters[self.db.turn] # Note the new character + self.db.timer = TURN_TIMEOUT + self.time_until_next_repeat() # Reset the timer. self.db.timeout_warning_given = False # Reset the timeout warning. self.obj.msg_contents("%s's turn ends - %s's turn begins!" % (currentchar, newchar)) self.start_turn(newchar) # Start the new character's turn. + + # Count down condition timers. + for fighter in self.db.fighters: + condition_tickdown(fighter, newchar) def turn_end_check(self, character): """ @@ -939,6 +979,32 @@ def itemfunc_heal(item, user, target, **kwargs): user.location.msg_contents("%s uses %s! %s regains %i HP!" % (user, item, target, to_heal)) +def itemfunc_add_condition(item, user, target, **kwargs): + """ + Item function that gives the target a condition. + + Should mostly be used for beneficial conditions - use itemfunc_attack + for an item that can give an enemy a harmful condition. + """ + condition = "Regeneration" + duration = 5 + + if not target: + target = user # Target user if none specified + + if not target.attributes.has("max_hp"): # Is not a fighter + user.msg("You can't use %s on that." % item) + return False # Returning false aborts the item use + + # Retrieve condition / duration from kwargs, if present + if "condition" in kwargs: + condition = kwargs["condition"] + if "duration" in kwargs: + duration = kwargs["duration"] + + user.location.msg_contents("%s uses %s!" % (user, item)) + add_condition(target, user, condition, duration) # Add condition to the target + def itemfunc_attack(item, user, target, **kwargs): """ Item function that attacks a target. @@ -982,7 +1048,8 @@ def itemfunc_attack(item, user, target, **kwargs): # a callable in this dictionary. ITEMFUNCS = { "heal":itemfunc_heal, - "attack":itemfunc_attack + "attack":itemfunc_attack, + "add_condition":itemfunc_add_condition } """ @@ -1015,6 +1082,15 @@ HEALTH_POTION = { "item_kwargs" : {"healing_range":(35, 50)} } +REGEN_POTION = { + "key" : "a regeneration potion", + "desc" : "A glass bottle full of a mystical potion that regenerates wounds over time.", + "item_func" : "add_condition", + "item_uses" : 1, + "item_consumable" : "GLASS_BOTTLE", + "item_kwargs" : {"condition":"Regeneration", "duration":10} +} + BOMB = { "key" : "a rotund bomb", "desc" : "A large black sphere with a fuse at the end. Can be used on enemies in combat.", From 228c4740bb1018471d944754e4d84e991a286c6e Mon Sep 17 00:00:00 2001 From: BattleJenkins Date: Sat, 18 Nov 2017 13:25:47 -0800 Subject: [PATCH 014/208] Fix condition ticking --- evennia/contrib/turnbattle/tb_items.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/evennia/contrib/turnbattle/tb_items.py b/evennia/contrib/turnbattle/tb_items.py index 8457a13bd6..b7cc6dd808 100644 --- a/evennia/contrib/turnbattle/tb_items.py +++ b/evennia/contrib/turnbattle/tb_items.py @@ -473,8 +473,8 @@ class TBItemsCharacter(DefaultCharacter): if not is_in_combat(self): # Not in combat # Apply conditions that fire every turn self.apply_turn_conditions() - # Tick down condition durations - condition_tickdown(self, self) + # Tick down condition durations + condition_tickdown(self, self) """ From 0a9d23c62ae4160a48a706a8c0971ce5614af44b Mon Sep 17 00:00:00 2001 From: BattleJenkins Date: Sat, 18 Nov 2017 17:28:52 -0800 Subject: [PATCH 015/208] Add "Poisoned" condition, more condition items Added the ability for attack items to inflict conditions on hit, as well as items that can cure specific conditions. --- evennia/contrib/turnbattle/tb_items.py | 70 ++++++++++++++++++++++++-- 1 file changed, 67 insertions(+), 3 deletions(-) diff --git a/evennia/contrib/turnbattle/tb_items.py b/evennia/contrib/turnbattle/tb_items.py index b7cc6dd808..22c619cbd8 100644 --- a/evennia/contrib/turnbattle/tb_items.py +++ b/evennia/contrib/turnbattle/tb_items.py @@ -200,7 +200,8 @@ def at_defeat(defeated): """ defeated.location.msg_contents("%s has been defeated!" % defeated) -def resolve_attack(attacker, defender, attack_value=None, defense_value=None, damage_value=None): +def resolve_attack(attacker, defender, attack_value=None, defense_value=None, + damage_value=None, inflict_condition=[]): """ Resolves an attack and outputs the result. @@ -228,6 +229,9 @@ def resolve_attack(attacker, defender, attack_value=None, defense_value=None, da # Announce damage dealt and apply damage. attacker.location.msg_contents("%s hits %s for %i damage!" % (attacker, defender, damage_value)) apply_damage(defender, damage_value) + # Inflict conditions on hit, if any specified + for condition in inflict_condition: + add_condition(defender, attacker, condition[0], condition[1]) # If defender HP is reduced to 0 or less, call at_defeat. if defender.db.hp <= 0: at_defeat(defender) @@ -456,12 +460,22 @@ class TBItemsCharacter(DefaultCharacter): Applies the effect of conditions that occur at the start of each turn in combat, or every 30 seconds out of combat. """ + # Regeneration: restores 4 to 8 HP at the start of character's turn if "Regeneration" in self.db.conditions: to_heal = randint(4, 8) # Restore 4 to 8 HP if self.db.hp + to_heal > self.db.max_hp: to_heal = self.db.max_hp - self.db.hp # Cap healing to max HP self.db.hp += to_heal self.location.msg_contents("%s regains %i HP from Regeneration." % (self, to_heal)) + + # Poisoned: does 4 to 8 damage at the start of character's turn + if "Poisoned" in self.db.conditions: + to_hurt = randint(4, 8) # Deal 4 to 8 damage + apply_damage(self, to_hurt) + self.location.msg_contents("%s takes %i damage from being Poisoned." % (self, to_hurt)) + if self.db.hp <= 0: + # Call at_defeat if poison defeats the character + at_defeat(self) def at_update(self): """ @@ -1005,6 +1019,33 @@ def itemfunc_add_condition(item, user, target, **kwargs): user.location.msg_contents("%s uses %s!" % (user, item)) add_condition(target, user, condition, duration) # Add condition to the target +def itemfunc_cure_condition(item, user, target, **kwargs): + """ + Item function that'll remove given conditions from a target. + """ + to_cure = ["Poisoned"] + + if not target: + target = user # Target user if none specified + + if not target.attributes.has("max_hp"): # Is not a fighter + user.msg("You can't use %s on that." % item) + return False # Returning false aborts the item use + + # Retrieve condition(s) to cure from kwargs, if present + if "to_cure" in kwargs: + to_cure = kwargs["to_cure"] + + item_msg = "%s uses %s! " % (user, item) + + for key in target.db.conditions: + if key in to_cure: + # If condition specified in to_cure, remove it. + item_msg += "%s no longer has the '%s' condition. " % (str(target), str(key)) + del target.db.conditions[key] + + user.location.msg_contents(item_msg) + def itemfunc_attack(item, user, target, **kwargs): """ Item function that attacks a target. @@ -1028,6 +1069,7 @@ def itemfunc_attack(item, user, target, **kwargs): min_damage = 20 max_damage = 40 accuracy = 0 + inflict_condition = [] # Retrieve values from kwargs, if present if "damage_range" in kwargs: @@ -1035,13 +1077,16 @@ def itemfunc_attack(item, user, target, **kwargs): max_damage = kwargs["damage_range"][1] if "accuracy" in kwargs: accuracy = kwargs["accuracy"] + if "inflict_condition" in kwargs: + inflict_condition = kwargs["inflict_condition"] # Roll attack and damage attack_value = randint(1, 100) + accuracy damage_value = randint(min_damage, max_damage) user.location.msg_contents("%s attacks %s with %s!" % (user, target, item)) - resolve_attack(user, target, attack_value=attack_value, damage_value=damage_value) + resolve_attack(user, target, attack_value=attack_value, + damage_value=damage_value, inflict_condition=inflict_condition) # Match strings to item functions here. We can't store callables on # prototypes, so we store a string instead, matching that string to @@ -1049,7 +1094,8 @@ def itemfunc_attack(item, user, target, **kwargs): ITEMFUNCS = { "heal":itemfunc_heal, "attack":itemfunc_attack, - "add_condition":itemfunc_add_condition + "add_condition":itemfunc_add_condition, + "cure_condition":itemfunc_cure_condition } """ @@ -1098,4 +1144,22 @@ BOMB = { "item_uses" : 1, "item_consumable" : True, "item_kwargs" : {"damage_range":(25, 40), "accuracy":25} +} + +POISON_DART = { + "key" : "a poison dart", + "desc" : "A thin dart coated in deadly poison. Can be used on enemies in combat", + "item_func" : "attack", + "item_uses" : 1, + "item_consumable" : True, + "item_kwargs" : {"damage_range":(5, 10), "accuracy":25, "inflict_condition":[("Poisoned", 10)]} +} + +ANTIDOTE_POTION = { + "key" : "an antidote potion", + "desc" : "A glass bottle full of a mystical potion that cures poison when used.", + "item_func" : "cure_condition", + "item_uses" : 1, + "item_consumable" : "GLASS_BOTTLE", + "item_kwargs" : {"to_cure":["Poisoned"]} } \ No newline at end of file From 93d5cb6036238840f21c7b6d0b3781c9d647fee0 Mon Sep 17 00:00:00 2001 From: BattleJenkins Date: Sat, 18 Nov 2017 18:02:54 -0800 Subject: [PATCH 016/208] More documentation, 'True' duration for indefinite conditions --- evennia/contrib/turnbattle/tb_items.py | 71 ++++++++++++++++++++++---- 1 file changed, 60 insertions(+), 11 deletions(-) diff --git a/evennia/contrib/turnbattle/tb_items.py b/evennia/contrib/turnbattle/tb_items.py index 22c619cbd8..1ab0e1d324 100644 --- a/evennia/contrib/turnbattle/tb_items.py +++ b/evennia/contrib/turnbattle/tb_items.py @@ -372,13 +372,15 @@ def condition_tickdown(character, turnchar): # The first value is the remaining turns - the second value is whose turn to count down on. condition_duration = character.db.conditions[key][0] condition_turnchar = character.db.conditions[key][1] - # Count down if the given turn character matches the condition's turn character. - if condition_turnchar == turnchar: - character.db.conditions[key][0] -= 1 - if character.db.conditions[key][0] <= 0: - # If the duration is brought down to 0, remove the condition and inform everyone. - character.location.msg_contents("%s no longer has the '%s' condition." % (str(character), str(key))) - del character.db.conditions[key] + # If the duration is 'True', then condition doesn't tick down - it lasts indefinitely. + if not condition_duration == True: + # Count down if the given turn character matches the condition's turn character. + if condition_turnchar == turnchar: + character.db.conditions[key][0] -= 1 + if character.db.conditions[key][0] <= 0: + # If the duration is brought down to 0, remove the condition and inform everyone. + character.location.msg_contents("%s no longer has the '%s' condition." % (str(character), str(key))) + del character.db.conditions[key] def add_condition(character, turnchar, condition, duration): """ @@ -960,12 +962,35 @@ class BattleCmdSet(default_cmds.CharacterCmdSet): self.add(CmdUse()) """ +---------------------------------------------------------------------------- ITEM FUNCTIONS START HERE +---------------------------------------------------------------------------- + +These functions carry out the action of using an item - every item should +contain a db entry "item_func", with its value being a string that is +matched to one of these functions in the ITEMFUNCS dictionary below. + +Every item function must take the following arguments: + item (obj): The item being used + user (obj): The character using the item + target (obj): The target of the item use + +Item functions must also accept **kwargs - these keyword arguments can be +used to define how different items that use the same function can have +different effects (for example, different attack items doing different +amounts of damage). + +Each function below contains a description of what kwargs the function will +take and the effect they have on the result. """ def itemfunc_heal(item, user, target, **kwargs): """ Item function that heals HP. + + kwargs: + min_healing(int): Minimum amount of HP recovered + max_healing(int): Maximum amount of HP recovered """ if not target: target = user # Target user if none specified @@ -997,8 +1022,13 @@ def itemfunc_add_condition(item, user, target, **kwargs): """ Item function that gives the target a condition. - Should mostly be used for beneficial conditions - use itemfunc_attack - for an item that can give an enemy a harmful condition. + kwargs: + condition(str): Condition added by the item + duration(int): Number of turns the condition lasts, or True for indefinite + + Notes: + Should mostly be used for beneficial conditions - use itemfunc_attack + for an item that can give an enemy a harmful condition. """ condition = "Regeneration" duration = 5 @@ -1022,6 +1052,9 @@ def itemfunc_add_condition(item, user, target, **kwargs): def itemfunc_cure_condition(item, user, target, **kwargs): """ Item function that'll remove given conditions from a target. + + kwargs: + to_cure(list): List of conditions (str) that the item cures when used """ to_cure = ["Poisoned"] @@ -1049,6 +1082,17 @@ def itemfunc_cure_condition(item, user, target, **kwargs): def itemfunc_attack(item, user, target, **kwargs): """ Item function that attacks a target. + + kwargs: + min_damage(int): Minimum damage dealt by the attack + max_damage(int): Maximum damage dealth by the attack + accuracy(int): Bonus / penalty to attack accuracy roll + inflict_condition(list): List of conditions inflicted on hit, + formatted as a (str, int) tuple containing condition name + and duration. + + Notes: + Calls resolve_attack at the end. """ if not is_in_combat(user): user.msg("You can only use that in combat.") @@ -1099,9 +1143,14 @@ ITEMFUNCS = { } """ -ITEM PROTOTYPES START HERE +---------------------------------------------------------------------------- +PROTOTYPES START HERE +---------------------------------------------------------------------------- -Copy these to your game's /world/prototypes.py module! +You can paste these prototypes into your game's prototypes.py module in your +/world/ folder, and use the spawner to create them - they serve as examples +of items you can make and a handy way to demonstrate the system for +conditions as well. """ MEDKIT = { From d60f23ec1ce0c65488203df6ac29e4369cb9f60a Mon Sep 17 00:00:00 2001 From: BattleJenkins Date: Sat, 18 Nov 2017 18:43:14 -0800 Subject: [PATCH 017/208] More documentation, fix error in at_update() at_update() erroneously changed the turnchar on conditions during combat - this has been fixed. --- evennia/contrib/turnbattle/tb_items.py | 73 +++++++++++++++++++++++--- 1 file changed, 65 insertions(+), 8 deletions(-) diff --git a/evennia/contrib/turnbattle/tb_items.py b/evennia/contrib/turnbattle/tb_items.py index 1ab0e1d324..db5be80be8 100644 --- a/evennia/contrib/turnbattle/tb_items.py +++ b/evennia/contrib/turnbattle/tb_items.py @@ -304,10 +304,17 @@ def spend_action(character, actions, action_name=None): def spend_item_use(item, user): """ - Spends one use on an item with limited uses. If item.db.item_consumable - is 'True', the item is destroyed if it runs out of uses - if it's a string - instead of 'True', it will also spawn a new object as residue, using the - value of item.db.item_consumable as the name of the prototype to spawn. + Spends one use on an item with limited uses. + + Args: + item (obj): Item being used + user (obj): Character using the item + + Notes: + If item.db.item_consumable is 'True', the item is destroyed if it + runs out of uses - if it's a string instead of 'True', it will also + spawn a new object as residue, using the value of item.db.item_consumable + as the name of the prototype to spawn. """ item.db.item_uses -= 1 # Spend one use @@ -335,7 +342,17 @@ def spend_item_use(item, user): def use_item(user, item, target): """ Performs the action of using an item. + + Args: + user (obj): Character using the item + item (obj): Item being used + target (obj): Target of the item use """ + # If item is self only, abort use + if item.db.item_selfonly and user == target: + user.msg("%s can only be used on yourself." % item) + return + # Set kwargs to pass to item_func kwargs = {} if item.db.item_kwargs: @@ -344,7 +361,7 @@ def use_item(user, item, target): # Match item_func string to function try: item_func = ITEMFUNCS[item.db.item_func] - except KeyError: + except KeyError: # If item_func string doesn't match to a function in ITEMFUNCS user.msg("ERROR: %s not defined in ITEMFUNCS" % item.db.item_func) return @@ -366,6 +383,15 @@ def use_item(user, item, target): def condition_tickdown(character, turnchar): """ Ticks down the duration of conditions on a character at the start of a given character's turn. + + Args: + character (obj): Character to tick down the conditions of + turnchar (obj): Character whose turn it currently is + + Notes: + In combat, this is called on every fighter at the start of every character's turn. Out of + combat, it's instead called when a character's at_update() hook is called, which is every + 30 seconds. """ for key in character.db.conditions: @@ -385,6 +411,12 @@ def condition_tickdown(character, turnchar): def add_condition(character, turnchar, condition, duration): """ Adds a condition to a fighter. + + Args: + character (obj): Character to give the condition to + turnchar (obj): Character whose turn to tick down the condition on in combat + condition (str): Name of the condition + duration (int or True): Number of turns the condition lasts, or True for indefinite """ # The first value is the remaining turns - the second value is whose turn to count down on. character.db.conditions.update({condition:[duration, turnchar]}) @@ -417,6 +449,11 @@ class TBItemsCharacter(DefaultCharacter): """ Adds attributes for a character's current and maximum HP. We're just going to set this value at '100' by default. + + An empty dictionary is created to store conditions later, + and the character is subscribed to the Ticker Handler, which + will call at_update() on the character every 30 seconds. This + is used to tick down conditions out of combat. You may want to expand this to include various 'stats' that can be changed at creation and factor into combat calculations. @@ -483,10 +520,10 @@ class TBItemsCharacter(DefaultCharacter): """ Fires every 30 seconds. """ - # Change all conditions to update on character's turn. - for key in self.db.conditions: - self.db.conditions[key][1] = self if not is_in_combat(self): # Not in combat + # Change all conditions to update on character's turn. + for key in self.db.conditions: + self.db.conditions[key][1] = self # Apply conditions that fire every turn self.apply_turn_conditions() # Tick down condition durations @@ -1151,6 +1188,26 @@ You can paste these prototypes into your game's prototypes.py module in your /world/ folder, and use the spawner to create them - they serve as examples of items you can make and a handy way to demonstrate the system for conditions as well. + +Items don't have any particular typeclass - any object with a db entry +"item_func" that references one of the functions given above can be used as +an item with the 'use' command. + +Only "item_func" is required, but item behavior can be further modified by +specifying any of the following: + + item_uses (int): If defined, item has a limited number of uses + + item_selfonly (bool): If True, user can only use the item on themself + + item_consumable(True or str): If True, item is destroyed when it runs + out of uses. If a string is given, the item will spawn a new + object as it's destroyed, with the string specifying what prototype + to spawn. + + item_kwargs (dict): Keyword arguments to pass to the function defined in + item_func. Unique to each function, and can be used to make multiple + items using the same function work differently. """ MEDKIT = { From ad2724630cdb2d188fc6965e8455e716b73823df Mon Sep 17 00:00:00 2001 From: FlutterSprite Date: Sun, 19 Nov 2017 12:16:38 -0800 Subject: [PATCH 018/208] More conditions and documentation --- evennia/contrib/turnbattle/tb_items.py | 116 ++++++++++++++++++------- 1 file changed, 84 insertions(+), 32 deletions(-) diff --git a/evennia/contrib/turnbattle/tb_items.py b/evennia/contrib/turnbattle/tb_items.py index db5be80be8..c101dca9a8 100644 --- a/evennia/contrib/turnbattle/tb_items.py +++ b/evennia/contrib/turnbattle/tb_items.py @@ -3,17 +3,34 @@ Simple turn-based combat system with items and status effects Contrib - Tim Ashley Jenkins 2017 -This is a version of the 'turnbattle' combat system that includes status -effects and usable items, which can instill these status effects, cure +This is a version of the 'turnbattle' combat system that includes +conditions and usable items, which can instill these conditions, cure them, or do just about anything else. -Status effects are stored on characters as a dictionary, where the key -is the name of the status effect and the value is a list of two items: -an integer representing the number of turns left until the status runs -out, and the character upon whose turn the condition timer is ticked -down. Unlike most combat-related attributes, conditions aren't wiped -once combat ends - if out of combat, they tick down in real time -instead. +Conditions are stored on characters as a dictionary, where the key +is the name of the condition and the value is a list of two items: +an integer representing the number of turns left until the condition +runs out, and the character upon whose turn the condition timer is +ticked down. Unlike most combat-related attributes, conditions aren't +wiped once combat ends - if out of combat, they tick down in real time +instead. + +This module includes a number of example conditions: + + Regeneration: Character recovers HP every turn + Poisoned: Character loses HP every turn + Accuracy Up: +25 to character's attack rolls + Accuracy Down: -25 to character's attack rolls + Damage Up: +5 to character's damage + Damage Down: -5 to character's damage + Defense Up: +15 to character's defense + Defense Down: -15 to character's defense + Haste: +1 action per turn + Paralyzed: No actions per turn + Frightened: Character can't use the 'attack' command + +Since conditions can have a wide variety of effects, their code is +scattered throughout the other functions wherever they may apply. Items aren't given any sort of special typeclass - instead, whether or not an object counts as an item is determined by its attributes. To make @@ -64,6 +81,7 @@ OPTIONS TURN_TIMEOUT = 30 # Time before turns automatically end, in seconds ACTIONS_PER_TURN = 1 # Number of actions allowed per turn +NONCOMBAT_TURN_TIME = 30 # Time per turn count out of combat """ ---------------------------------------------------------------------------- @@ -111,15 +129,18 @@ def get_attack(attacker, defender): to determine whether an attack hits or misses. Notes: - By default, returns a random integer from 1 to 100 without using any - properties from either the attacker or defender. - - This can easily be expanded to return a value based on characters stats, - equipment, and abilities. This is why the attacker and defender are passed - to this function, even though nothing from either one are used in this example. + This is where conditions affecting attack rolls are applied, as well. + Accuracy Up and Accuracy Down are also accounted for in itemfunc_attack(), + so that attack items' accuracy is affected as well. """ # For this example, just return a random integer up to 100. attack_value = randint(1, 100) + # Add 25 to the roll if the attacker has the "Accuracy Up" condition. + if "Accuracy Up" in attacker.db.conditions: + attack_value += 25 + # Subtract 25 from the roll if the attack has the "Accuracy Down" condition. + if "Accuracy Down" in attacker.db.conditions: + attack_value -= 25 return attack_value @@ -137,13 +158,16 @@ def get_defense(attacker, defender): to determine whether an attack hits or misses. Notes: - By default, returns 50, not taking any properties of the defender or - attacker into account. - - As above, this can be expanded upon based on character stats and equipment. + This is where conditions affecting defense are accounted for. """ # For this example, just return 50, for about a 50/50 chance of hit. defense_value = 50 + # Add 15 to defense if the defender has the "Defense Up" condition. + if "Defense Up" in defender.db.conditions: + defense_value += 15 + # Subtract 15 from defense if the defender has the "Defense Down" condition. + if "Defense Down" in defender.db.conditions: + defense_value -= 15 return defense_value @@ -161,13 +185,18 @@ def get_damage(attacker, defender): character's HP. Notes: - By default, returns a random integer from 15 to 25 without using any - properties from either the attacker or defender. - - Again, this can be expanded upon. + This is where conditions affecting damage are accounted for. Since attack items + roll their own damage in itemfunc_attack(), their damage is unaffected by any + conditions. """ # For this example, just generate a number between 15 and 25. damage_value = randint(15, 25) + # Add 5 to damage roll if attacker has the "Damage Up" condition. + if "Damage Up" in attacker.db.conditions: + damage_value += 5 + # Subtract 5 from the roll if the attacker has the "Damage Down" condition. + if "Damage Down" in attacker.db.conditions: + damage_value -= 5 return damage_value @@ -208,11 +237,17 @@ def resolve_attack(attacker, defender, attack_value=None, defense_value=None, Args: attacker (obj): Character doing the attacking defender (obj): Character being attacked + + Options: + attack_value (int): Override for attack roll + defense_value (int): Override for defense value + damage_value (int): Override for damage value + inflict_condition (list): Conditions to inflict upon hit, a + list of tuples formated as (condition(str), duration(int)) Notes: - Even though the attack and defense values are calculated - extremely simply, they are separated out into their own functions - so that they are easier to expand upon. + This function is called by normal attacks as well as attacks + made with items. """ # Get an attack roll from the attacker. if not attack_value: @@ -391,14 +426,14 @@ def condition_tickdown(character, turnchar): Notes: In combat, this is called on every fighter at the start of every character's turn. Out of combat, it's instead called when a character's at_update() hook is called, which is every - 30 seconds. + 30 seconds by default. """ for key in character.db.conditions: # The first value is the remaining turns - the second value is whose turn to count down on. condition_duration = character.db.conditions[key][0] condition_turnchar = character.db.conditions[key][1] - # If the duration is 'True', then condition doesn't tick down - it lasts indefinitely. + # If the duration is 'True', then the condition doesn't tick down - it lasts indefinitely. if not condition_duration == True: # Count down if the given turn character matches the condition's turn character. if condition_turnchar == turnchar: @@ -445,15 +480,16 @@ class TBItemsCharacter(DefaultCharacter): self.db.hp = self.db.max_hp # Set current HP to maximum self.db.conditions = {} # Set empty dict for conditions # Subscribe character to the ticker handler - tickerhandler.add(30, self.at_update) + tickerhandler.add(NONCOMBAT_TURN_TIME, self.at_update) """ Adds attributes for a character's current and maximum HP. We're just going to set this value at '100' by default. An empty dictionary is created to store conditions later, and the character is subscribed to the Ticker Handler, which - will call at_update() on the character every 30 seconds. This - is used to tick down conditions out of combat. + will call at_update() on the character, with the interval + specified by NONCOMBAT_TURN_TIME above. This is used to tick + down conditions out of combat. You may want to expand this to include various 'stats' that can be changed at creation and factor into combat calculations. @@ -515,6 +551,16 @@ class TBItemsCharacter(DefaultCharacter): if self.db.hp <= 0: # Call at_defeat if poison defeats the character at_defeat(self) + + # Haste: Gain an extra action in combat. + if is_in_combat(self) and "Haste" in self.db.conditions: + self.db.combat_actionsleft += 1 + self.msg("You gain an extra action this turn from Haste!") + + # Paralyzed: Have no actions in combat. + if is_in_combat(self) and "Paralyzed" in self.db.conditions: + self.db.combat_actionsleft = 0 + self.msg("You're Paralyzed, and can't act this turn!") def at_update(self): """ @@ -791,6 +837,10 @@ class CmdAttack(Command): if not self.caller.db.hp: # Can't attack if you have no HP. self.caller.msg("You can't attack, you've been defeated.") return + + if "Frightened" in self.caller.db.conditions: # Can't attack if frightened + self.caller.msg("You're too frightened to attack!") + return attacker = self.caller defender = self.caller.search(self.args) @@ -939,7 +989,9 @@ class CmdUse(MuxCommand): Usage: use [= target] - Items: you just GOTTA use them. + An item can have various function - looking at the item may + provide information as to its effects. Some items can be used + to attack others, and as such can only be used in combat. """ key = "use" From ea12145ce12a278c6d0947d9f1f5afd64b311bf9 Mon Sep 17 00:00:00 2001 From: FlutterSprite Date: Sun, 19 Nov 2017 12:59:07 -0800 Subject: [PATCH 019/208] Fixed all conditions lasting indefinitely Turns out 1 == True, but not 1 is True - learn something new every day! --- evennia/contrib/turnbattle/tb_items.py | 49 +++++++++++++++++++------- 1 file changed, 36 insertions(+), 13 deletions(-) diff --git a/evennia/contrib/turnbattle/tb_items.py b/evennia/contrib/turnbattle/tb_items.py index c101dca9a8..6a30b3cda3 100644 --- a/evennia/contrib/turnbattle/tb_items.py +++ b/evennia/contrib/turnbattle/tb_items.py @@ -384,7 +384,7 @@ def use_item(user, item, target): target (obj): Target of the item use """ # If item is self only, abort use - if item.db.item_selfonly and user == target: + if item.db.item_selfonly and user != target: user.msg("%s can only be used on yourself." % item) return @@ -434,7 +434,7 @@ def condition_tickdown(character, turnchar): condition_duration = character.db.conditions[key][0] condition_turnchar = character.db.conditions[key][1] # If the duration is 'True', then the condition doesn't tick down - it lasts indefinitely. - if not condition_duration == True: + if not condition_duration is True: # Count down if the given turn character matches the condition's turn character. if condition_turnchar == turnchar: character.db.conditions[key][0] -= 1 @@ -1109,18 +1109,17 @@ def itemfunc_heal(item, user, target, **kwargs): def itemfunc_add_condition(item, user, target, **kwargs): """ - Item function that gives the target a condition. + Item function that gives the target one or more conditions. kwargs: - condition(str): Condition added by the item - duration(int): Number of turns the condition lasts, or True for indefinite + conditions (list): Conditions added by the item + formatted as a list of tuples: (condition (str), duration (int or True)) Notes: Should mostly be used for beneficial conditions - use itemfunc_attack for an item that can give an enemy a harmful condition. """ - condition = "Regeneration" - duration = 5 + conditions = [("Regeneration", 5)] if not target: target = user # Target user if none specified @@ -1130,13 +1129,14 @@ def itemfunc_add_condition(item, user, target, **kwargs): return False # Returning false aborts the item use # Retrieve condition / duration from kwargs, if present - if "condition" in kwargs: - condition = kwargs["condition"] - if "duration" in kwargs: - duration = kwargs["duration"] + if "conditions" in kwargs: + conditions = kwargs["conditions"] user.location.msg_contents("%s uses %s!" % (user, item)) - add_condition(target, user, condition, duration) # Add condition to the target + + # Add conditions to the target + for condition in conditions: + add_condition(target, user, condition[0], condition[1]) def itemfunc_cure_condition(item, user, target, **kwargs): """ @@ -1217,6 +1217,12 @@ def itemfunc_attack(item, user, target, **kwargs): attack_value = randint(1, 100) + accuracy damage_value = randint(min_damage, max_damage) + # Account for "Accuracy Up" and "Accuracy Down" conditions + if "Accuracy Up" in user.db.conditions: + attack_value += 25 + if "Accuracy Down" in user.db.conditions: + attack_value -= 25 + user.location.msg_contents("%s attacks %s with %s!" % (user, target, item)) resolve_attack(user, target, attack_value=attack_value, damage_value=damage_value, inflict_condition=inflict_condition) @@ -1292,7 +1298,16 @@ REGEN_POTION = { "item_func" : "add_condition", "item_uses" : 1, "item_consumable" : "GLASS_BOTTLE", - "item_kwargs" : {"condition":"Regeneration", "duration":10} + "item_kwargs" : {"conditions":[("Regeneration", 10)]} +} + +HASTE_POTION = { + "key" : "a haste potion", + "desc" : "A glass bottle full of a mystical potion that hastens its user.", + "item_func" : "add_condition", + "item_uses" : 1, + "item_consumable" : "GLASS_BOTTLE", + "item_kwargs" : {"conditions":[("Haste", 10)]} } BOMB = { @@ -1320,4 +1335,12 @@ ANTIDOTE_POTION = { "item_uses" : 1, "item_consumable" : "GLASS_BOTTLE", "item_kwargs" : {"to_cure":["Poisoned"]} +} + +AMULET_OF_MIGHT = { + "key" : "The Amulet of Might", + "desc" : "The one who holds this amulet can call upon its power to gain great strength.", + "item_func" : "add_condition", + "item_selfonly" : True, + "item_kwargs" : {"conditions":[("Damage Up", 3), ("Accuracy Up", 3), ("Defense Up", 3)]} } \ No newline at end of file From fcac893f9473182fe93a070e7a96de52af8aa70e Mon Sep 17 00:00:00 2001 From: FlutterSprite Date: Sun, 19 Nov 2017 13:18:55 -0800 Subject: [PATCH 020/208] More item prototypes - probably ready to go! --- evennia/contrib/turnbattle/tb_items.py | 32 ++++++++++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/evennia/contrib/turnbattle/tb_items.py b/evennia/contrib/turnbattle/tb_items.py index 6a30b3cda3..25b7625991 100644 --- a/evennia/contrib/turnbattle/tb_items.py +++ b/evennia/contrib/turnbattle/tb_items.py @@ -383,7 +383,11 @@ def use_item(user, item, target): item (obj): Item being used target (obj): Target of the item use """ - # If item is self only, abort use + # If item is self only and no target given, set target to self. + if item.db.item_selfonly and target == None: + target = user + + # If item is self only, abort use if used on others. if item.db.item_selfonly and user != target: user.msg("%s can only be used on yourself." % item) return @@ -560,7 +564,8 @@ class TBItemsCharacter(DefaultCharacter): # Paralyzed: Have no actions in combat. if is_in_combat(self) and "Paralyzed" in self.db.conditions: self.db.combat_actionsleft = 0 - self.msg("You're Paralyzed, and can't act this turn!") + self.location.msg_contents("%s is Paralyzed, and can't act this turn!" % self) + self.db.combat_turnhandler.turn_end_check(self) def at_update(self): """ @@ -1328,6 +1333,21 @@ POISON_DART = { "item_kwargs" : {"damage_range":(5, 10), "accuracy":25, "inflict_condition":[("Poisoned", 10)]} } +TASER = { + "key" : "a taser", + "desc" : "A device that can be used to paralyze enemies in combat.", + "item_func" : "attack", + "item_kwargs" : {"damage_range":(10, 20), "accuracy":0, "inflict_condition":[("Paralyzed", 1)]} +} + +GHOST_GUN = { + "key" : "a ghost gun", + "desc" : "A gun that fires scary ghosts at people. Anyone hit by a ghost becomes frightened.", + "item_func" : "attack", + "item_uses" : 6, + "item_kwargs" : {"damage_range":(5, 10), "accuracy":15, "inflict_condition":[("Frightened", 1)]} +} + ANTIDOTE_POTION = { "key" : "an antidote potion", "desc" : "A glass bottle full of a mystical potion that cures poison when used.", @@ -1343,4 +1363,12 @@ AMULET_OF_MIGHT = { "item_func" : "add_condition", "item_selfonly" : True, "item_kwargs" : {"conditions":[("Damage Up", 3), ("Accuracy Up", 3), ("Defense Up", 3)]} +} + +AMULET_OF_WEAKNESS = { + "key" : "The Amulet of Weakness", + "desc" : "The one who holds this amulet can call upon its power to gain great weakness. It's not a terribly useful artifact.", + "item_func" : "add_condition", + "item_selfonly" : True, + "item_kwargs" : {"conditions":[("Damage Down", 3), ("Accuracy Down", 3), ("Defense Down", 3)]} } \ No newline at end of file From 6417aaa70b8e6fc97f38fb509ec0e36dd2d6e08a Mon Sep 17 00:00:00 2001 From: FlutterSprite Date: Sun, 19 Nov 2017 13:24:44 -0800 Subject: [PATCH 021/208] Update readme --- evennia/contrib/turnbattle/README.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/evennia/contrib/turnbattle/README.md b/evennia/contrib/turnbattle/README.md index 729c42a099..d5c86d90f3 100644 --- a/evennia/contrib/turnbattle/README.md +++ b/evennia/contrib/turnbattle/README.md @@ -21,6 +21,19 @@ implemented and customized: the battle system, including commands for wielding weapons and donning armor, and modifiers to accuracy and damage based on currently used equipment. + + tb_items.py - Adds usable items and conditions/status effects, and gives + a lot of examples for each. Items can perform nearly any sort of + function, including healing, adding or curing conditions, or + being used to attack. Conditions affect a fighter's attributes + and options in combat and persist outside of fights, counting + down per turn in combat and in real time outside combat. + + tb_magic.py - Adds a spellcasting system, allowing characters to cast + spells with a variety of effects by spending MP. Spells are + linked to functions, and as such can perform any sort of action + the developer can imagine - spells for attacking, healing and + conjuring objects are included as examples. tb_range.py - Adds a system for abstract positioning and movement, which tracks the distance between different characters and objects in From 0b714a2d0a390265c476bfe9bf42af6dbec819a9 Mon Sep 17 00:00:00 2001 From: FlutterSprite Date: Sun, 19 Nov 2017 13:25:37 -0800 Subject: [PATCH 022/208] Fix readme spacing --- evennia/contrib/turnbattle/README.md | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/evennia/contrib/turnbattle/README.md b/evennia/contrib/turnbattle/README.md index d5c86d90f3..fd2563bceb 100644 --- a/evennia/contrib/turnbattle/README.md +++ b/evennia/contrib/turnbattle/README.md @@ -23,17 +23,17 @@ implemented and customized: currently used equipment. tb_items.py - Adds usable items and conditions/status effects, and gives - a lot of examples for each. Items can perform nearly any sort of - function, including healing, adding or curing conditions, or - being used to attack. Conditions affect a fighter's attributes - and options in combat and persist outside of fights, counting - down per turn in combat and in real time outside combat. + a lot of examples for each. Items can perform nearly any sort of + function, including healing, adding or curing conditions, or + being used to attack. Conditions affect a fighter's attributes + and options in combat and persist outside of fights, counting + down per turn in combat and in real time outside combat. tb_magic.py - Adds a spellcasting system, allowing characters to cast - spells with a variety of effects by spending MP. Spells are - linked to functions, and as such can perform any sort of action - the developer can imagine - spells for attacking, healing and - conjuring objects are included as examples. + spells with a variety of effects by spending MP. Spells are + linked to functions, and as such can perform any sort of action + the developer can imagine - spells for attacking, healing and + conjuring objects are included as examples. tb_range.py - Adds a system for abstract positioning and movement, which tracks the distance between different characters and objects in From 0fae1126430233cd3988081b1b1cddd6b8fd8daa Mon Sep 17 00:00:00 2001 From: BattleJenkins Date: Wed, 29 Nov 2017 13:54:22 -0800 Subject: [PATCH 023/208] Added options for conditions at top of module --- evennia/contrib/turnbattle/tb_items.py | 38 ++++++++++++++++---------- 1 file changed, 24 insertions(+), 14 deletions(-) diff --git a/evennia/contrib/turnbattle/tb_items.py b/evennia/contrib/turnbattle/tb_items.py index 25b7625991..98a39774c4 100644 --- a/evennia/contrib/turnbattle/tb_items.py +++ b/evennia/contrib/turnbattle/tb_items.py @@ -83,6 +83,16 @@ TURN_TIMEOUT = 30 # Time before turns automatically end, in seconds ACTIONS_PER_TURN = 1 # Number of actions allowed per turn NONCOMBAT_TURN_TIME = 30 # Time per turn count out of combat +# Condition options start here +REGEN_RATE = (4, 8) # Min and max HP regen for Regeneration +POISON_RATE = (4, 8) # Min and max damage for Poisoned +ACC_UP_MOD = 25 # Accuracy Up attack roll bonus +ACC_DOWN_MOD = -25 # Accuracy Down attack roll penalty +DMG_UP_MOD = 5 # Damage Up damage roll bonus +DMG_DOWN_MOD = -5 # Damage Down damage roll penalty +DEF_UP_MOD = 15 # Defense Up defense bonus +DEF_DOWN_MOD = -15 # Defense Down defense penalty + """ ---------------------------------------------------------------------------- COMBAT FUNCTIONS START HERE @@ -135,12 +145,12 @@ def get_attack(attacker, defender): """ # For this example, just return a random integer up to 100. attack_value = randint(1, 100) - # Add 25 to the roll if the attacker has the "Accuracy Up" condition. + # Add to the roll if the attacker has the "Accuracy Up" condition. if "Accuracy Up" in attacker.db.conditions: - attack_value += 25 - # Subtract 25 from the roll if the attack has the "Accuracy Down" condition. + attack_value += ACC_UP_MOD + # Subtract from the roll if the attack has the "Accuracy Down" condition. if "Accuracy Down" in attacker.db.conditions: - attack_value -= 25 + attack_value += ACC_DOWN_MOD return attack_value @@ -162,12 +172,12 @@ def get_defense(attacker, defender): """ # For this example, just return 50, for about a 50/50 chance of hit. defense_value = 50 - # Add 15 to defense if the defender has the "Defense Up" condition. + # Add to defense if the defender has the "Defense Up" condition. if "Defense Up" in defender.db.conditions: - defense_value += 15 - # Subtract 15 from defense if the defender has the "Defense Down" condition. + defense_value += DEF_UP_MOD + # Subtract from defense if the defender has the "Defense Down" condition. if "Defense Down" in defender.db.conditions: - defense_value -= 15 + defense_value += DEF_DOWN_MOD return defense_value @@ -191,12 +201,12 @@ def get_damage(attacker, defender): """ # For this example, just generate a number between 15 and 25. damage_value = randint(15, 25) - # Add 5 to damage roll if attacker has the "Damage Up" condition. + # Add to damage roll if attacker has the "Damage Up" condition. if "Damage Up" in attacker.db.conditions: - damage_value += 5 - # Subtract 5 from the roll if the attacker has the "Damage Down" condition. + damage_value += DMG_UP_MOD + # Subtract from the roll if the attacker has the "Damage Down" condition. if "Damage Down" in attacker.db.conditions: - damage_value -= 5 + damage_value += DMG_DOWN_MOD return damage_value @@ -541,7 +551,7 @@ class TBItemsCharacter(DefaultCharacter): """ # Regeneration: restores 4 to 8 HP at the start of character's turn if "Regeneration" in self.db.conditions: - to_heal = randint(4, 8) # Restore 4 to 8 HP + to_heal = randint(REGEN_RATE[0], REGEN_RAGE[1]) # Restore HP if self.db.hp + to_heal > self.db.max_hp: to_heal = self.db.max_hp - self.db.hp # Cap healing to max HP self.db.hp += to_heal @@ -549,7 +559,7 @@ class TBItemsCharacter(DefaultCharacter): # Poisoned: does 4 to 8 damage at the start of character's turn if "Poisoned" in self.db.conditions: - to_hurt = randint(4, 8) # Deal 4 to 8 damage + to_hurt = randint(POISON_RATE[0], POISON_RATE[1]) # Deal damage apply_damage(self, to_hurt) self.location.msg_contents("%s takes %i damage from being Poisoned." % (self, to_hurt)) if self.db.hp <= 0: From 273b66267fa48454a8575ee0cf403151a7ac7d3d Mon Sep 17 00:00:00 2001 From: BattleJenkins Date: Wed, 29 Nov 2017 15:01:49 -0800 Subject: [PATCH 024/208] Unit tests for tb_items --- evennia/contrib/tests.py | 125 ++++++++++++++++++++++++- evennia/contrib/turnbattle/tb_items.py | 5 +- 2 files changed, 128 insertions(+), 2 deletions(-) diff --git a/evennia/contrib/tests.py b/evennia/contrib/tests.py index 4076ade4dd..6c5798cb34 100644 --- a/evennia/contrib/tests.py +++ b/evennia/contrib/tests.py @@ -916,7 +916,7 @@ class TestTutorialWorldRooms(CommandTest): # test turnbattle -from evennia.contrib.turnbattle import tb_basic, tb_equip, tb_range +from evennia.contrib.turnbattle import tb_basic, tb_equip, tb_range, tb_items from evennia.objects.objects import DefaultRoom @@ -962,6 +962,18 @@ class TestTurnBattleCmd(CommandTest): self.call(tb_range.CmdDisengage(), "", "You can only do that in combat. (see: help fight)") self.call(tb_range.CmdRest(), "", "Char rests to recover HP.") + # Test item commands + def test_turnbattlecmd(self): + testitem = create_object(key="test item") + testitem.move_to(self.char1) + self.call(tb_items.CmdUse(), "item", "'Test item' is not a usable item.") + # Also test the commands that are the same in the basic module + self.call(tb_items.CmdFight(), "", "You can't start a fight if you've been defeated!") + self.call(tb_items.CmdAttack(), "", "You can only do that in combat. (see: help fight)") + self.call(tb_items.CmdPass(), "", "You can only do that in combat. (see: help fight)") + self.call(tb_items.CmdDisengage(), "", "You can only do that in combat. (see: help fight)") + self.call(tb_items.CmdRest(), "", "Char rests to recover HP.") + class TestTurnBattleFunc(EvenniaTest): @@ -1214,6 +1226,117 @@ class TestTurnBattleFunc(EvenniaTest): self.assertTrue(tb_range.get_range(attacker, defender) == 1) # Remove the script at the end turnhandler.stop() + + # Test functions in tb_items. + def test_tbitemsfunc(self): + attacker = create_object(tb_items.TBItemsCharacter, key="Attacker") + defender = create_object(tb_items.TBItemsCharacter, key="Defender") + testroom = create_object(DefaultRoom, key="Test Room") + attacker.location = testroom + defender.loaction = testroom + # Initiative roll + initiative = tb_items.roll_init(attacker) + self.assertTrue(initiative >= 0 and initiative <= 1000) + # Attack roll + attack_roll = tb_items.get_attack(attacker, defender) + self.assertTrue(attack_roll >= 0 and attack_roll <= 100) + # Defense roll + defense_roll = tb_items.get_defense(attacker, defender) + self.assertTrue(defense_roll == 50) + # Damage roll + damage_roll = tb_items.get_damage(attacker, defender) + self.assertTrue(damage_roll >= 15 and damage_roll <= 25) + # Apply damage + defender.db.hp = 10 + tb_items.apply_damage(defender, 3) + self.assertTrue(defender.db.hp == 7) + # Resolve attack + defender.db.hp = 40 + tb_items.resolve_attack(attacker, defender, attack_value=20, defense_value=10) + self.assertTrue(defender.db.hp < 40) + # Combat cleanup + attacker.db.Combat_attribute = True + tb_items.combat_cleanup(attacker) + self.assertFalse(attacker.db.combat_attribute) + # Is in combat + self.assertFalse(tb_items.is_in_combat(attacker)) + # Set up turn handler script for further tests + attacker.location.scripts.add(tb_items.TBItemsTurnHandler) + turnhandler = attacker.db.combat_TurnHandler + self.assertTrue(attacker.db.combat_TurnHandler) + # Set the turn handler's interval very high to keep it from repeating during tests. + turnhandler.interval = 10000 + # Force turn order + turnhandler.db.fighters = [attacker, defender] + turnhandler.db.turn = 0 + # Test is turn + self.assertTrue(tb_items.is_turn(attacker)) + # Spend actions + attacker.db.Combat_ActionsLeft = 1 + tb_items.spend_action(attacker, 1, action_name="Test") + self.assertTrue(attacker.db.Combat_ActionsLeft == 0) + self.assertTrue(attacker.db.Combat_LastAction == "Test") + # Initialize for combat + attacker.db.Combat_ActionsLeft = 983 + turnhandler.initialize_for_combat(attacker) + self.assertTrue(attacker.db.Combat_ActionsLeft == 0) + self.assertTrue(attacker.db.Combat_LastAction == "null") + # Start turn + defender.db.Combat_ActionsLeft = 0 + turnhandler.start_turn(defender) + self.assertTrue(defender.db.Combat_ActionsLeft == 1) + # Next turn + turnhandler.db.fighters = [attacker, defender] + turnhandler.db.turn = 0 + turnhandler.next_turn() + self.assertTrue(turnhandler.db.turn == 1) + # Turn end check + turnhandler.db.fighters = [attacker, defender] + turnhandler.db.turn = 0 + attacker.db.Combat_ActionsLeft = 0 + turnhandler.turn_end_check(attacker) + self.assertTrue(turnhandler.db.turn == 1) + # Join fight + joiner = create_object(tb_items.TBItemsCharacter, key="Joiner") + turnhandler.db.fighters = [attacker, defender] + turnhandler.db.turn = 0 + turnhandler.join_fight(joiner) + self.assertTrue(turnhandler.db.turn == 1) + self.assertTrue(turnhandler.db.fighters == [joiner, attacker, defender]) + # Remove the script at the end + turnhandler.stop() + # Now time to test item stuff. + user = create_object(tb_items.TBItemsCharacter, key="User") + testroom = create_object(DefaultRoom, key="Test Room") + user.location = testroom + test_healpotion = create_object(key="healing potion") + test_healpotion.db.item_func = "heal" + test_healpotion.db.item_uses = 3 + # Spend item use + tb_items.spend_item_use(test_healpotion, user) + self.assertTrue(test_healpotion.db.item_uses == 2) + # Use item + user.db.hp = 2 + tb_items.use_item(user, test_healpotion, user) + self.assertTrue(user.db.hp > 2) + # Add contition + tb_items.add_condition(user, user, "Test", 5) + self.assertTrue(user.db.conditions == {"Test":[5, user]}) + # Condition tickdown + tb_items.condition_tickdown(user, user) + self.assertTrue(user.db.conditions == {"Test":[4, user]}) + # Test item functions now! + # Item heal + user.db.hp = 2 + tb_items.itemfunc_heal(test_healpotion, user, user) + # Item add condition + user.db.conditions = {} + tb_items.itemfunc_add_condition(test_healpotion, user, user) + self.assertTrue(user.db.conditions == {"Regeneration":[5, user]}) + # Item cure condition + user.db.conditions = {"Poisoned":[5, user]} + tb_items.itemfunc_cure_condition(test_healpotion, user, user) + self.assertTrue(user.db.conditions == {}) # Test tree select diff --git a/evennia/contrib/turnbattle/tb_items.py b/evennia/contrib/turnbattle/tb_items.py index 98a39774c4..f367fec269 100644 --- a/evennia/contrib/turnbattle/tb_items.py +++ b/evennia/contrib/turnbattle/tb_items.py @@ -83,7 +83,10 @@ TURN_TIMEOUT = 30 # Time before turns automatically end, in seconds ACTIONS_PER_TURN = 1 # Number of actions allowed per turn NONCOMBAT_TURN_TIME = 30 # Time per turn count out of combat -# Condition options start here +# Condition options start here. +# If you need to make changes to how your conditions work later, +# it's best to put the easily tweakable values all in one place! + REGEN_RATE = (4, 8) # Min and max HP regen for Regeneration POISON_RATE = (4, 8) # Min and max damage for Poisoned ACC_UP_MOD = 25 # Accuracy Up attack roll bonus From 24866df8cd4d46e265cb3bad110f58de5a349fe8 Mon Sep 17 00:00:00 2001 From: BattleJenkins Date: Wed, 29 Nov 2017 15:08:55 -0800 Subject: [PATCH 025/208] Attempt to fix TickerHandler error in unit tests --- evennia/contrib/tests.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/evennia/contrib/tests.py b/evennia/contrib/tests.py index 6c5798cb34..bf03036117 100644 --- a/evennia/contrib/tests.py +++ b/evennia/contrib/tests.py @@ -1337,6 +1337,8 @@ class TestTurnBattleFunc(EvenniaTest): user.db.conditions = {"Poisoned":[5, user]} tb_items.itemfunc_cure_condition(test_healpotion, user, user) self.assertTrue(user.db.conditions == {}) + # Delete the test character to prevent ticker handler problems + user.delete() # Test tree select From d6a5af1f214ff911f65c6562211a906c58ea10e2 Mon Sep 17 00:00:00 2001 From: BattleJenkins Date: Wed, 29 Nov 2017 15:15:47 -0800 Subject: [PATCH 026/208] Manually unsubscribe ticker handler --- evennia/contrib/tests.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/evennia/contrib/tests.py b/evennia/contrib/tests.py index bf03036117..04a1753933 100644 --- a/evennia/contrib/tests.py +++ b/evennia/contrib/tests.py @@ -1307,6 +1307,7 @@ class TestTurnBattleFunc(EvenniaTest): turnhandler.stop() # Now time to test item stuff. user = create_object(tb_items.TBItemsCharacter, key="User") + user.TICKER_HANDLER.remove(interval=30, callback=user.at_update) testroom = create_object(DefaultRoom, key="Test Room") user.location = testroom test_healpotion = create_object(key="healing potion") @@ -1337,7 +1338,7 @@ class TestTurnBattleFunc(EvenniaTest): user.db.conditions = {"Poisoned":[5, user]} tb_items.itemfunc_cure_condition(test_healpotion, user, user) self.assertTrue(user.db.conditions == {}) - # Delete the test character to prevent ticker handler problems + # Delete the test character user.delete() # Test tree select From 8cd33261fa93759612f54623febc011060bd40f7 Mon Sep 17 00:00:00 2001 From: BattleJenkins Date: Wed, 29 Nov 2017 15:29:49 -0800 Subject: [PATCH 027/208] TickerHandler stuff, more --- evennia/contrib/tests.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/evennia/contrib/tests.py b/evennia/contrib/tests.py index 04a1753933..7a3c660a72 100644 --- a/evennia/contrib/tests.py +++ b/evennia/contrib/tests.py @@ -1307,7 +1307,7 @@ class TestTurnBattleFunc(EvenniaTest): turnhandler.stop() # Now time to test item stuff. user = create_object(tb_items.TBItemsCharacter, key="User") - user.TICKER_HANDLER.remove(interval=30, callback=user.at_update) + tb_items.tickerhandler.remove(interval=30, callback=user.at_update) testroom = create_object(DefaultRoom, key="Test Room") user.location = testroom test_healpotion = create_object(key="healing potion") From 36070c8defc3dd79b37812c6f9200ce69ef64b35 Mon Sep 17 00:00:00 2001 From: BattleJenkins Date: Wed, 29 Nov 2017 15:37:40 -0800 Subject: [PATCH 028/208] Ugh!!! TickerHandler changes, more --- evennia/contrib/tests.py | 2 +- evennia/contrib/turnbattle/tb_items.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/evennia/contrib/tests.py b/evennia/contrib/tests.py index 7a3c660a72..8d40aeaadb 100644 --- a/evennia/contrib/tests.py +++ b/evennia/contrib/tests.py @@ -1307,7 +1307,7 @@ class TestTurnBattleFunc(EvenniaTest): turnhandler.stop() # Now time to test item stuff. user = create_object(tb_items.TBItemsCharacter, key="User") - tb_items.tickerhandler.remove(interval=30, callback=user.at_update) + tb_items.tickerhandler.remove(interval=30, callback=user.at_update, idstring="update") testroom = create_object(DefaultRoom, key="Test Room") user.location = testroom test_healpotion = create_object(key="healing potion") diff --git a/evennia/contrib/turnbattle/tb_items.py b/evennia/contrib/turnbattle/tb_items.py index f367fec269..dca5856fe5 100644 --- a/evennia/contrib/turnbattle/tb_items.py +++ b/evennia/contrib/turnbattle/tb_items.py @@ -497,7 +497,7 @@ class TBItemsCharacter(DefaultCharacter): self.db.hp = self.db.max_hp # Set current HP to maximum self.db.conditions = {} # Set empty dict for conditions # Subscribe character to the ticker handler - tickerhandler.add(NONCOMBAT_TURN_TIME, self.at_update) + tickerhandler.add(NONCOMBAT_TURN_TIME, self.at_update, idstring="update") """ Adds attributes for a character's current and maximum HP. We're just going to set this value at '100' by default. From 3576219a2f8242b3fc6da7043597b0d613fa3d1c Mon Sep 17 00:00:00 2001 From: FlutterSprite Date: Wed, 29 Nov 2017 20:08:22 -0800 Subject: [PATCH 029/208] Comment out tb_items tests for now --- evennia/contrib/tests.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/evennia/contrib/tests.py b/evennia/contrib/tests.py index 8d40aeaadb..9091fa65ec 100644 --- a/evennia/contrib/tests.py +++ b/evennia/contrib/tests.py @@ -1316,6 +1316,8 @@ class TestTurnBattleFunc(EvenniaTest): # Spend item use tb_items.spend_item_use(test_healpotion, user) self.assertTrue(test_healpotion.db.item_uses == 2) + # Commenting this stuff out just to make sure it's the problem. + """ # Use item user.db.hp = 2 tb_items.use_item(user, test_healpotion, user) @@ -1340,6 +1342,7 @@ class TestTurnBattleFunc(EvenniaTest): self.assertTrue(user.db.conditions == {}) # Delete the test character user.delete() + """ # Test tree select From 4234ad2373f902b00d0b5f664aa93ea80ca70013 Mon Sep 17 00:00:00 2001 From: FlutterSprite Date: Wed, 29 Nov 2017 20:18:54 -0800 Subject: [PATCH 030/208] Also remove ticker handler for 'attacker' and 'defender' Whoops! I forgot that ALL my test characters are getting subscribed to the ticker handler here - maybe that's the problem? --- evennia/contrib/tests.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/evennia/contrib/tests.py b/evennia/contrib/tests.py index 9091fa65ec..8de5d61fe9 100644 --- a/evennia/contrib/tests.py +++ b/evennia/contrib/tests.py @@ -1230,7 +1230,9 @@ class TestTurnBattleFunc(EvenniaTest): # Test functions in tb_items. def test_tbitemsfunc(self): attacker = create_object(tb_items.TBItemsCharacter, key="Attacker") + tb_items.tickerhandler.remove(interval=30, callback=attacker.at_update, idstring="update") defender = create_object(tb_items.TBItemsCharacter, key="Defender") + tb_items.tickerhandler.remove(interval=30, callback=defender.at_update, idstring="update") testroom = create_object(DefaultRoom, key="Test Room") attacker.location = testroom defender.loaction = testroom @@ -1316,8 +1318,6 @@ class TestTurnBattleFunc(EvenniaTest): # Spend item use tb_items.spend_item_use(test_healpotion, user) self.assertTrue(test_healpotion.db.item_uses == 2) - # Commenting this stuff out just to make sure it's the problem. - """ # Use item user.db.hp = 2 tb_items.use_item(user, test_healpotion, user) @@ -1342,7 +1342,6 @@ class TestTurnBattleFunc(EvenniaTest): self.assertTrue(user.db.conditions == {}) # Delete the test character user.delete() - """ # Test tree select From 619bf013489770b08fd86be168491c278b030fb8 Mon Sep 17 00:00:00 2001 From: FlutterSprite Date: Wed, 29 Nov 2017 20:26:26 -0800 Subject: [PATCH 031/208] Just comment it all out. Travis won't even tell me why it failed this time. --- evennia/contrib/tests.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/evennia/contrib/tests.py b/evennia/contrib/tests.py index 8de5d61fe9..088377380a 100644 --- a/evennia/contrib/tests.py +++ b/evennia/contrib/tests.py @@ -1226,7 +1226,8 @@ class TestTurnBattleFunc(EvenniaTest): self.assertTrue(tb_range.get_range(attacker, defender) == 1) # Remove the script at the end turnhandler.stop() - + + """ # Test functions in tb_items. def test_tbitemsfunc(self): attacker = create_object(tb_items.TBItemsCharacter, key="Attacker") @@ -1342,6 +1343,7 @@ class TestTurnBattleFunc(EvenniaTest): self.assertTrue(user.db.conditions == {}) # Delete the test character user.delete() + """" # Test tree select From ccdc0979dfd1bd9cfdb4aa46760e81459358fb0e Mon Sep 17 00:00:00 2001 From: FlutterSprite Date: Wed, 29 Nov 2017 20:32:00 -0800 Subject: [PATCH 032/208] Comment it out right this time --- evennia/contrib/tests.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/evennia/contrib/tests.py b/evennia/contrib/tests.py index 088377380a..5e8a73bb19 100644 --- a/evennia/contrib/tests.py +++ b/evennia/contrib/tests.py @@ -1227,7 +1227,7 @@ class TestTurnBattleFunc(EvenniaTest): # Remove the script at the end turnhandler.stop() - """ +""" # Test functions in tb_items. def test_tbitemsfunc(self): attacker = create_object(tb_items.TBItemsCharacter, key="Attacker") @@ -1343,7 +1343,7 @@ class TestTurnBattleFunc(EvenniaTest): self.assertTrue(user.db.conditions == {}) # Delete the test character user.delete() - """" +""" # Test tree select From 961a86b7be335f5f13703e355b2d0666c7136318 Mon Sep 17 00:00:00 2001 From: BattleJenkins Date: Sun, 10 Dec 2017 18:50:34 -0800 Subject: [PATCH 033/208] Test character class without tickerhandler --- evennia/contrib/tests.py | 9 ++++----- evennia/contrib/turnbattle/tb_items.py | 12 +++++++++++- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/evennia/contrib/tests.py b/evennia/contrib/tests.py index 8d40aeaadb..8af7fe59eb 100644 --- a/evennia/contrib/tests.py +++ b/evennia/contrib/tests.py @@ -1229,8 +1229,8 @@ class TestTurnBattleFunc(EvenniaTest): # Test functions in tb_items. def test_tbitemsfunc(self): - attacker = create_object(tb_items.TBItemsCharacter, key="Attacker") - defender = create_object(tb_items.TBItemsCharacter, key="Defender") + attacker = create_object(tb_items.TBItemsCharacterTest, key="Attacker") + defender = create_object(tb_items.TBItemsCharacterTest, key="Defender") testroom = create_object(DefaultRoom, key="Test Room") attacker.location = testroom defender.loaction = testroom @@ -1297,7 +1297,7 @@ class TestTurnBattleFunc(EvenniaTest): turnhandler.turn_end_check(attacker) self.assertTrue(turnhandler.db.turn == 1) # Join fight - joiner = create_object(tb_items.TBItemsCharacter, key="Joiner") + joiner = create_object(tb_items.TBItemsCharacterTest, key="Joiner") turnhandler.db.fighters = [attacker, defender] turnhandler.db.turn = 0 turnhandler.join_fight(joiner) @@ -1306,8 +1306,7 @@ class TestTurnBattleFunc(EvenniaTest): # Remove the script at the end turnhandler.stop() # Now time to test item stuff. - user = create_object(tb_items.TBItemsCharacter, key="User") - tb_items.tickerhandler.remove(interval=30, callback=user.at_update, idstring="update") + user = create_object(tb_items.TBItemsCharacterTest, key="User") testroom = create_object(DefaultRoom, key="Test Room") user.location = testroom test_healpotion = create_object(key="healing potion") diff --git a/evennia/contrib/turnbattle/tb_items.py b/evennia/contrib/turnbattle/tb_items.py index dca5856fe5..a51edbbbdc 100644 --- a/evennia/contrib/turnbattle/tb_items.py +++ b/evennia/contrib/turnbattle/tb_items.py @@ -592,7 +592,17 @@ class TBItemsCharacter(DefaultCharacter): self.apply_turn_conditions() # Tick down condition durations condition_tickdown(self, self) - + +class TBItemsCharacterTest(TBItemsCharacter): + """ + Just like the TBItemsCharacter, but doesn't subscribe to the TickerHandler. + This makes it easier to run unit tests on. + """ + def at_object_creation(self): + self.db.max_hp = 100 # Set maximum HP to 100 + self.db.hp = self.db.max_hp # Set current HP to maximum + self.db.conditions = {} # Set empty dict for conditions + """ ---------------------------------------------------------------------------- From 81ac38ec238e9d4d96c9692759c57985a4de576a Mon Sep 17 00:00:00 2001 From: FlutterSprite Date: Sun, 10 Dec 2017 18:52:34 -0800 Subject: [PATCH 034/208] Add tickerhandler-free character class for tests --- evennia/contrib/turnbattle/tb_items.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/evennia/contrib/turnbattle/tb_items.py b/evennia/contrib/turnbattle/tb_items.py index dca5856fe5..d0e9fe8e34 100644 --- a/evennia/contrib/turnbattle/tb_items.py +++ b/evennia/contrib/turnbattle/tb_items.py @@ -592,7 +592,17 @@ class TBItemsCharacter(DefaultCharacter): self.apply_turn_conditions() # Tick down condition durations condition_tickdown(self, self) - + +class TBItemsCharacterTest(TBItemsCharacter): + """ + Just like the TBItemsCharacter, but doesn't subscribe to the TickerHandler. + This makes it easier to run unit tests on. + """ + def at_object_creation(self): + self.db.max_hp = 100 # Set maximum HP to 100 + self.db.hp = self.db.max_hp # Set current HP to maximum + self.db.conditions = {} # Set empty dict for conditions + """ ---------------------------------------------------------------------------- @@ -1384,4 +1394,4 @@ AMULET_OF_WEAKNESS = { "item_func" : "add_condition", "item_selfonly" : True, "item_kwargs" : {"conditions":[("Damage Down", 3), ("Accuracy Down", 3), ("Defense Down", 3)]} -} \ No newline at end of file +} From f550c03411f8b71711ad08db9e05001c43892d56 Mon Sep 17 00:00:00 2001 From: FlutterSprite Date: Sun, 10 Dec 2017 18:53:10 -0800 Subject: [PATCH 035/208] Use test character class instead --- evennia/contrib/tests.py | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/evennia/contrib/tests.py b/evennia/contrib/tests.py index 5e8a73bb19..8af7fe59eb 100644 --- a/evennia/contrib/tests.py +++ b/evennia/contrib/tests.py @@ -1226,14 +1226,11 @@ class TestTurnBattleFunc(EvenniaTest): self.assertTrue(tb_range.get_range(attacker, defender) == 1) # Remove the script at the end turnhandler.stop() - -""" + # Test functions in tb_items. def test_tbitemsfunc(self): - attacker = create_object(tb_items.TBItemsCharacter, key="Attacker") - tb_items.tickerhandler.remove(interval=30, callback=attacker.at_update, idstring="update") - defender = create_object(tb_items.TBItemsCharacter, key="Defender") - tb_items.tickerhandler.remove(interval=30, callback=defender.at_update, idstring="update") + attacker = create_object(tb_items.TBItemsCharacterTest, key="Attacker") + defender = create_object(tb_items.TBItemsCharacterTest, key="Defender") testroom = create_object(DefaultRoom, key="Test Room") attacker.location = testroom defender.loaction = testroom @@ -1300,7 +1297,7 @@ class TestTurnBattleFunc(EvenniaTest): turnhandler.turn_end_check(attacker) self.assertTrue(turnhandler.db.turn == 1) # Join fight - joiner = create_object(tb_items.TBItemsCharacter, key="Joiner") + joiner = create_object(tb_items.TBItemsCharacterTest, key="Joiner") turnhandler.db.fighters = [attacker, defender] turnhandler.db.turn = 0 turnhandler.join_fight(joiner) @@ -1309,8 +1306,7 @@ class TestTurnBattleFunc(EvenniaTest): # Remove the script at the end turnhandler.stop() # Now time to test item stuff. - user = create_object(tb_items.TBItemsCharacter, key="User") - tb_items.tickerhandler.remove(interval=30, callback=user.at_update, idstring="update") + user = create_object(tb_items.TBItemsCharacterTest, key="User") testroom = create_object(DefaultRoom, key="Test Room") user.location = testroom test_healpotion = create_object(key="healing potion") @@ -1343,7 +1339,6 @@ class TestTurnBattleFunc(EvenniaTest): self.assertTrue(user.db.conditions == {}) # Delete the test character user.delete() -""" # Test tree select From 9e6de0846c11c1a41f316e9be48adb45d4af9ff4 Mon Sep 17 00:00:00 2001 From: BattleJenkins Date: Mon, 11 Dec 2017 14:35:18 -0800 Subject: [PATCH 036/208] Unit tests for tb_magic --- evennia/contrib/tests.py | 96 +++++++++++++++++++++++++- evennia/contrib/turnbattle/tb_magic.py | 8 +++ 2 files changed, 102 insertions(+), 2 deletions(-) diff --git a/evennia/contrib/tests.py b/evennia/contrib/tests.py index 8af7fe59eb..5203354fff 100644 --- a/evennia/contrib/tests.py +++ b/evennia/contrib/tests.py @@ -916,7 +916,7 @@ class TestTutorialWorldRooms(CommandTest): # test turnbattle -from evennia.contrib.turnbattle import tb_basic, tb_equip, tb_range, tb_items +from evennia.contrib.turnbattle import tb_basic, tb_equip, tb_range, tb_items, tb_magic from evennia.objects.objects import DefaultRoom @@ -963,7 +963,7 @@ class TestTurnBattleCmd(CommandTest): self.call(tb_range.CmdRest(), "", "Char rests to recover HP.") # Test item commands - def test_turnbattlecmd(self): + def test_turnbattleitemcmd(self): testitem = create_object(key="test item") testitem.move_to(self.char1) self.call(tb_items.CmdUse(), "item", "'Test item' is not a usable item.") @@ -974,6 +974,18 @@ class TestTurnBattleCmd(CommandTest): self.call(tb_items.CmdDisengage(), "", "You can only do that in combat. (see: help fight)") self.call(tb_items.CmdRest(), "", "Char rests to recover HP.") + # Test magic commands + def test_turnbattlemagiccmd(self): + self.call(tb_magic.CmdStatus(), "", "You have 100 / 100 HP and 20 / 20 MP.") + self.call(tb_magic.CmdLearnSpell(), "test spell", "There is no spell with that name.") + self.call(tb_magic.CmdCast(), "", "Usage: cast = , ") + # Also test the commands that are the same in the basic module + self.call(tb_magic.CmdFight(), "", "There's nobody here to fight!") + self.call(tb_magic.CmdAttack(), "", "You can only do that in combat. (see: help fight)") + self.call(tb_magic.CmdPass(), "", "You can only do that in combat. (see: help fight)") + self.call(tb_magic.CmdDisengage(), "", "You can only do that in combat. (see: help fight)") + self.call(tb_magic.CmdRest(), "", "Char rests to recover HP and MP.") + class TestTurnBattleFunc(EvenniaTest): @@ -1339,6 +1351,86 @@ class TestTurnBattleFunc(EvenniaTest): self.assertTrue(user.db.conditions == {}) # Delete the test character user.delete() + + # Test combat functions in tb_magic. + def test_tbbasicfunc(self): + attacker = create_object(tb_magic.TBMagicCharacter, key="Attacker") + defender = create_object(tb_magic.TBMagicCharacter, key="Defender") + testroom = create_object(DefaultRoom, key="Test Room") + attacker.location = testroom + defender.loaction = testroom + # Initiative roll + initiative = tb_magic.roll_init(attacker) + self.assertTrue(initiative >= 0 and initiative <= 1000) + # Attack roll + attack_roll = tb_magic.get_attack(attacker, defender) + self.assertTrue(attack_roll >= 0 and attack_roll <= 100) + # Defense roll + defense_roll = tb_magic.get_defense(attacker, defender) + self.assertTrue(defense_roll == 50) + # Damage roll + damage_roll = tb_magic.get_damage(attacker, defender) + self.assertTrue(damage_roll >= 15 and damage_roll <= 25) + # Apply damage + defender.db.hp = 10 + tb_magic.apply_damage(defender, 3) + self.assertTrue(defender.db.hp == 7) + # Resolve attack + defender.db.hp = 40 + tb_magic.resolve_attack(attacker, defender, attack_value=20, defense_value=10) + self.assertTrue(defender.db.hp < 40) + # Combat cleanup + attacker.db.Combat_attribute = True + tb_magic.combat_cleanup(attacker) + self.assertFalse(attacker.db.combat_attribute) + # Is in combat + self.assertFalse(tb_magic.is_in_combat(attacker)) + # Set up turn handler script for further tests + attacker.location.scripts.add(tb_magic.TBMagicTurnHandler) + turnhandler = attacker.db.combat_TurnHandler + self.assertTrue(attacker.db.combat_TurnHandler) + # Set the turn handler's interval very high to keep it from repeating during tests. + turnhandler.interval = 10000 + # Force turn order + turnhandler.db.fighters = [attacker, defender] + turnhandler.db.turn = 0 + # Test is turn + self.assertTrue(tb_magic.is_turn(attacker)) + # Spend actions + attacker.db.Combat_ActionsLeft = 1 + tb_magic.spend_action(attacker, 1, action_name="Test") + self.assertTrue(attacker.db.Combat_ActionsLeft == 0) + self.assertTrue(attacker.db.Combat_LastAction == "Test") + # Initialize for combat + attacker.db.Combat_ActionsLeft = 983 + turnhandler.initialize_for_combat(attacker) + self.assertTrue(attacker.db.Combat_ActionsLeft == 0) + self.assertTrue(attacker.db.Combat_LastAction == "null") + # Start turn + defender.db.Combat_ActionsLeft = 0 + turnhandler.start_turn(defender) + self.assertTrue(defender.db.Combat_ActionsLeft == 1) + # Next turn + turnhandler.db.fighters = [attacker, defender] + turnhandler.db.turn = 0 + turnhandler.next_turn() + self.assertTrue(turnhandler.db.turn == 1) + # Turn end check + turnhandler.db.fighters = [attacker, defender] + turnhandler.db.turn = 0 + attacker.db.Combat_ActionsLeft = 0 + turnhandler.turn_end_check(attacker) + self.assertTrue(turnhandler.db.turn == 1) + # Join fight + joiner = create_object(tb_magic.TBMagicCharacter, key="Joiner") + turnhandler.db.fighters = [attacker, defender] + turnhandler.db.turn = 0 + turnhandler.join_fight(joiner) + self.assertTrue(turnhandler.db.turn == 1) + self.assertTrue(turnhandler.db.fighters == [joiner, attacker, defender]) + # Remove the script at the end + turnhandler.stop() + # Test tree select diff --git a/evennia/contrib/turnbattle/tb_magic.py b/evennia/contrib/turnbattle/tb_magic.py index 6e16cd0d46..01101837b3 100644 --- a/evennia/contrib/turnbattle/tb_magic.py +++ b/evennia/contrib/turnbattle/tb_magic.py @@ -1000,6 +1000,14 @@ class CmdStatus(Command): def func(self): "This performs the actual command." char = self.caller + + if not char.db.max_hp: # Character not initialized, IE in unit tests + char.db.hp = 100 + char.db.max_hp = 100 + char.db.spells_known = [] + char.db.max_mp = 20 + char.db.mp = char.db.max_mp + char.msg("You have %i / %i HP and %i / %i MP." % (char.db.hp, char.db.max_hp, char.db.mp, char.db.max_mp)) class CmdCombatHelp(CmdHelp): From 43185d8f17ba65cf17b07e70d6ccd88122bd2eeb Mon Sep 17 00:00:00 2001 From: Griatch Date: Wed, 28 Feb 2018 19:17:26 +0100 Subject: [PATCH 037/208] OLC systen. Create olc_storage mechanism --- evennia/accounts/accounts.py | 21 +++++ evennia/objects/objects.py | 6 +- evennia/scripts/scripts.py | 16 ++++ evennia/typeclasses/attributes.py | 4 +- evennia/utils/create.py | 16 +++- evennia/utils/olc/__init__.py | 0 evennia/utils/olc/olc_storage.py | 151 ++++++++++++++++++++++++++++++ 7 files changed, 205 insertions(+), 9 deletions(-) create mode 100644 evennia/utils/olc/__init__.py create mode 100644 evennia/utils/olc/olc_storage.py diff --git a/evennia/accounts/accounts.py b/evennia/accounts/accounts.py index b86b6eb16e..602ccc2a69 100644 --- a/evennia/accounts/accounts.py +++ b/evennia/accounts/accounts.py @@ -631,10 +631,31 @@ class DefaultAccount(with_metaclass(TypeclassBase, AccountDB)): # this will only be set if the utils.create_account # function was used to create the object. cdict = self._createdict + updates = [] + if not cdict.get("key"): + if not self.db_key: + self.db_key = "#%i" % self.dbid + updates.append("db_key") + elif self.key != cdict.get("key"): + updates.append("db_key") + self.db_key = cdict["key"] + if updates: + self.save(update_fields=updates) + if cdict.get("locks"): self.locks.add(cdict["locks"]) if cdict.get("permissions"): permissions = cdict["permissions"] + if cdict.get("tags"): + # this should be a list of tags, tuples (key, category) or (key, category, data) + self.tags.batch_add(*cdict["tags"]) + if cdict.get("attributes"): + # this should be tuples (key, val, ...) + self.attributes.batch_add(*cdict["attributes"]) + if cdict.get("nattributes"): + # this should be a dict of nattrname:value + for key, value in cdict["nattributes"]: + self.nattributes.add(key, value) del self._createdict self.permissions.batch_add(*permissions) diff --git a/evennia/objects/objects.py b/evennia/objects/objects.py index 037f3ff019..6cb948da03 100644 --- a/evennia/objects/objects.py +++ b/evennia/objects/objects.py @@ -1002,14 +1002,14 @@ class DefaultObject(with_metaclass(TypeclassBase, ObjectDB)): cdict["location"].at_object_receive(self, None) self.at_after_move(None) if cdict.get("tags"): - # this should be a list of tags + # this should be a list of tags, tuples (key, category) or (key, category, data) self.tags.batch_add(*cdict["tags"]) if cdict.get("attributes"): - # this should be a dict of attrname:value + # this should be tuples (key, val, ...) self.attributes.batch_add(*cdict["attributes"]) if cdict.get("nattributes"): # this should be a dict of nattrname:value - for key, value in cdict["nattributes"].items(): + for key, value in cdict["nattributes"]: self.nattributes.add(key, value) del self._createdict diff --git a/evennia/scripts/scripts.py b/evennia/scripts/scripts.py index db6e9652cb..24e25592fa 100644 --- a/evennia/scripts/scripts.py +++ b/evennia/scripts/scripts.py @@ -513,6 +513,22 @@ class DefaultScript(ScriptBase): updates.append("db_persistent") if updates: self.save(update_fields=updates) + + if cdict.get("permissions"): + self.permissions.batch_add(*cdict["permissions"]) + if cdict.get("locks"): + self.locks.add(cdict["locks"]) + if cdict.get("tags"): + # this should be a list of tags, tuples (key, category) or (key, category, data) + self.tags.batch_add(*cdict["tags"]) + if cdict.get("attributes"): + # this should be tuples (key, val, ...) + self.attributes.batch_add(*cdict["attributes"]) + if cdict.get("nattributes"): + # this should be a dict of nattrname:value + for key, value in cdict["nattributes"]: + self.nattributes.add(key, value) + if not cdict.get("autostart"): # don't auto-start the script return diff --git a/evennia/typeclasses/attributes.py b/evennia/typeclasses/attributes.py index 3f8b4cd742..03ef255093 100644 --- a/evennia/typeclasses/attributes.py +++ b/evennia/typeclasses/attributes.py @@ -530,8 +530,8 @@ class AttributeHandler(object): repeat-calling add when having many Attributes to add. Args: - indata (tuple): Tuples of varying length representing the - Attribute to add to this object. + indata (list): List of tuples of varying length representing the + Attribute to add to this object. Supported tuples are - `(key, value)` - `(key, value, category)` - `(key, value, category, lockstring)` diff --git a/evennia/utils/create.py b/evennia/utils/create.py index 1404e4caaa..c5fb6f8416 100644 --- a/evennia/utils/create.py +++ b/evennia/utils/create.py @@ -54,7 +54,8 @@ _GA = object.__getattribute__ def create_object(typeclass=None, key=None, location=None, home=None, permissions=None, locks=None, aliases=None, tags=None, - destination=None, report_to=None, nohome=False): + destination=None, report_to=None, nohome=False, attributes=None, + nattributes=None): """ Create a new in-game object. @@ -68,13 +69,18 @@ def create_object(typeclass=None, key=None, location=None, home=None, permissions (list): A list of permission strings or tuples (permstring, category). locks (str): one or more lockstrings, separated by semicolons. aliases (list): A list of alternative keys or tuples (aliasstring, category). - tags (list): List of tag keys or tuples (tagkey, category). + tags (list): List of tag keys or tuples (tagkey, category) or (tagkey, category, data). destination (Object or str): Obj or #dbref to use as an Exit's target. report_to (Object): The object to return error messages to. nohome (bool): This allows the creation of objects without a default home location; only used when creating the default location itself or during unittests. + attributes (list): Tuples on the form (key, value) or (key, value, category), + (key, value, lockstring) or (key, value, lockstring, default_access). + to set as Attributes on the new object. + nattributes (list): Non-persistent tuples on the form (key, value). Note that + adding this rarely makes sense since this data will not survive a reload. Returns: object (Object): A newly created object of the given typeclass. @@ -122,7 +128,8 @@ def create_object(typeclass=None, key=None, location=None, home=None, # store the call signature for the signal new_object._createdict = dict(key=key, location=location, destination=destination, home=home, typeclass=typeclass.path, permissions=permissions, locks=locks, - aliases=aliases, tags=tags, report_to=report_to, nohome=nohome) + aliases=aliases, tags=tags, report_to=report_to, nohome=nohome, + attributes=attributes, nattributes=nattributes) # this will trigger the save signal which in turn calls the # at_first_save hook on the typeclass, where the _createdict can be # used. @@ -139,7 +146,8 @@ object = create_object def create_script(typeclass=None, key=None, obj=None, account=None, locks=None, interval=None, start_delay=None, repeats=None, - persistent=None, autostart=True, report_to=None, desc=None): + persistent=None, autostart=True, report_to=None, desc=None, + tags=None, attributes=None): """ Create a new script. All scripts are a combination of a database object that communicates with the database, and an typeclass that diff --git a/evennia/utils/olc/__init__.py b/evennia/utils/olc/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/evennia/utils/olc/olc_storage.py b/evennia/utils/olc/olc_storage.py new file mode 100644 index 0000000000..d64956f8a0 --- /dev/null +++ b/evennia/utils/olc/olc_storage.py @@ -0,0 +1,151 @@ +""" +OLC storage and sharing mechanism. + +This sets up a central storage for prototypes. The idea is to make these +available in a repository for buildiers to use. Each prototype is stored +in a Script so that it can be tagged for quick sorting/finding and locked for limiting +access. + +""" + +from evennia.scripts.scripts import DefaultScript +from evennia.utils.create import create_script +from evennia.utils.utils import make_iter +from evennia.utils.evtable import EvTable + + +class PersistentPrototype(DefaultScript): + """ + This stores a single prototype + """ + key = "persistent_prototype" + desc = "Stores a prototoype" + + +def store_prototype(caller, key, prototype, desc="", tags=None, locks="", delete=False): + """ + Store a prototype persistently. + + Args: + caller (Account or Object): Caller aiming to store prototype. At this point + the caller should have permission to 'add' new prototypes, but to edit + an existing prototype, the 'edit' lock must be passed on that prototype. + key (str): Name of prototype to store. + prototype (dict): Prototype dict. + desc (str, optional): Description of prototype, to use in listing. + tags (list, optional): Tag-strings to apply to prototype. These are always + applied with the 'persistent_prototype' category. + locks (str, optional): Locks to apply to this prototype. Used locks + are 'use' and 'edit' + delete (bool, optional): Delete an existing prototype identified by 'key'. + This requires `caller` to pass the 'edit' lock of the prototype. + Returns: + stored (StoredPrototype or None): The resulting prototype (new or edited), + or None if deleting. + Raises: + PermissionError: If edit lock was not passed by caller. + + """ + key = key.lower() + locks = locks if locks else "use:all();edit:id({}) or edit:perm(Admin)".format(caller.id) + tags = [(tag, "persistent_prototype") for tag in make_iter(tags)] + + stored_prototype = PersistentPrototype.objects.filter(db_key=key) + + if stored_prototype: + stored_prototype = stored_prototype[0] + if not stored_prototype.access(caller, 'edit'): + PermissionError("{} does not have permission to edit prototype {}".format(caller, key)) + + if delete: + stored_prototype.delete() + return + + if desc: + stored_prototype.desc = desc + if tags: + stored_prototype.tags.add(tags) + if locks: + stored_prototype.locks.add(locks) + if prototype: + stored_prototype.attributes.add("prototype", prototype) + else: + stored_prototype = create_script( + PersistentPrototype, key=key, desc=desc, persistent=True, + locks=locks, tags=tags, attributes=[("prototype", prototype)]) + return stored_prototype + + +def search_prototype(key=None, tags=None): + """ + Find prototypes based on key and/or tags. + + Kwargs: + key (str): An exact or partial key to query for. + tags (str or list): Tag key or keys to query for. These + will always be applied with the 'persistent_protototype' + tag category. + Return: + matches (queryset): All found PersistentPrototypes. This will + be all prototypes if no arguments are given. + + Note: + This will use the tags to make a subselection before attempting + to match on the key. So if key/tags don't match up nothing will + be found. + + """ + matches = PersistentPrototype.objects.all() + if tags: + # exact match on tag(s) + tags = make_iter(tags) + tag_categories = ("persistent_prototype" for _ in tags) + matches = matches.get_by_tag(tags, tag_categories) + if key: + # partial match on key + matches = matches.filter(db_key=key) or matches.filter(db_key__icontains=key) + return matches + + +def get_prototype_list(caller, key=None, tags=None, show_non_use=False, show_non_edit=True): + """ + Collate a list of found prototypes based on search criteria and access. + + Args: + caller (Account or Object): The object requesting the list. + key (str, optional): Exact or partial key to query for. + tags (str or list, optional): Tag key or keys to query for. + show_non_use (bool, optional): Show also prototypes the caller may not use. + show_non_edit (bool, optional): Show also prototypes the caller may not edit. + Returns: + table (EvTable or None): An EvTable representation of the prototypes. None + if no prototypes were found. + + """ + prototypes = search_prototype(key, tags) + + if not prototypes: + return None + + # gather access permissions as (key, desc, can_use, can_edit) + prototypes = [(prototype.key, prototype.desc, + prototype.access(caller, "use"), prototype.access(caller, "edit")) + for prototype in prototypes] + + if not show_non_use: + prototypes = [tup for tup in prototypes if tup[2]] + if not show_non_edit: + prototypes = [tup for tup in prototypes if tup[3]] + + if not prototypes: + return None + + table = [] + for i in range(len(prototypes[0])): + table.append([tup[i] for tup in prototypes]) + table = EvTable("Key", "Desc", "Use", "Edit", table, crop=True, width=78) + table.reformat_column(0, width=28) + table.reformat_column(1, width=40) + table.reformat_column(2, width=5) + table.reformat_column(3, width=5) + return table From 4e488ff2a2375fc84199b593b85ecadf07e0c7d5 Mon Sep 17 00:00:00 2001 From: Griatch Date: Fri, 2 Mar 2018 23:16:15 +0100 Subject: [PATCH 038/208] Correct bugs in script_creation, fix unittest for olc_storage --- evennia/scripts/scripts.py | 171 ++++++++++++++++--------------- evennia/utils/create.py | 14 ++- evennia/utils/olc/olc_storage.py | 18 ++-- 3 files changed, 110 insertions(+), 93 deletions(-) diff --git a/evennia/scripts/scripts.py b/evennia/scripts/scripts.py index 24e25592fa..67367df3bb 100644 --- a/evennia/scripts/scripts.py +++ b/evennia/scripts/scripts.py @@ -141,15 +141,6 @@ class ScriptBase(with_metaclass(TypeclassBase, ScriptDB)): """ objects = ScriptManager() - -class DefaultScript(ScriptBase): - """ - This is the base TypeClass for all Scripts. Scripts describe - events, timers and states in game, they can have a time component - or describe a state that changes under certain conditions. - - """ - def __eq__(self, other): """ Compares two Scripts. Compares dbids. @@ -239,7 +230,96 @@ class DefaultScript(ScriptBase): logger.log_trace() return None - # Public methods + def at_script_creation(self): + """ + Should be overridden in child. + + """ + pass + + def at_first_save(self, **kwargs): + """ + This is called after very first time this object is saved. + Generally, you don't need to overload this, but only the hooks + called by this method. + + Args: + **kwargs (dict): Arbitrary, optional arguments for users + overriding the call (unused by default). + + """ + self.at_script_creation() + + if hasattr(self, "_createdict"): + # this will only be set if the utils.create_script + # function was used to create the object. We want + # the create call's kwargs to override the values + # set by hooks. + cdict = self._createdict + updates = [] + if not cdict.get("key"): + if not self.db_key: + self.db_key = "#%i" % self.dbid + updates.append("db_key") + elif self.db_key != cdict["key"]: + self.db_key = cdict["key"] + updates.append("db_key") + if cdict.get("interval") and self.interval != cdict["interval"]: + self.db_interval = cdict["interval"] + updates.append("db_interval") + if cdict.get("start_delay") and self.start_delay != cdict["start_delay"]: + self.db_start_delay = cdict["start_delay"] + updates.append("db_start_delay") + if cdict.get("repeats") and self.repeats != cdict["repeats"]: + self.db_repeats = cdict["repeats"] + updates.append("db_repeats") + if cdict.get("persistent") and self.persistent != cdict["persistent"]: + self.db_persistent = cdict["persistent"] + updates.append("db_persistent") + if cdict.get("desc") and self.desc != cdict["desc"]: + self.db_desc = cdict["desc"] + updates.append("db_desc") + if updates: + self.save(update_fields=updates) + + if cdict.get("permissions"): + self.permissions.batch_add(*cdict["permissions"]) + if cdict.get("locks"): + self.locks.add(cdict["locks"]) + if cdict.get("tags"): + # this should be a list of tags, tuples (key, category) or (key, category, data) + self.tags.batch_add(*cdict["tags"]) + if cdict.get("attributes"): + # this should be tuples (key, val, ...) + self.attributes.batch_add(*cdict["attributes"]) + if cdict.get("nattributes"): + # this should be a dict of nattrname:value + for key, value in cdict["nattributes"]: + self.nattributes.add(key, value) + + if not cdict.get("autostart"): + # don't auto-start the script + return + + # auto-start script (default) + self.start() + + +class DefaultScript(ScriptBase): + """ + This is the base TypeClass for all Scripts. Scripts describe + events, timers and states in game, they can have a time component + or describe a state that changes under certain conditions. + + """ + + def at_script_creation(self): + """ + Only called once, when script is first created. + + """ + pass + def time_until_next_repeat(self): """ @@ -472,77 +552,6 @@ class DefaultScript(ScriptBase): if task: task.force_repeat() - def at_first_save(self, **kwargs): - """ - This is called after very first time this object is saved. - Generally, you don't need to overload this, but only the hooks - called by this method. - - Args: - **kwargs (dict): Arbitrary, optional arguments for users - overriding the call (unused by default). - - """ - self.at_script_creation() - - if hasattr(self, "_createdict"): - # this will only be set if the utils.create_script - # function was used to create the object. We want - # the create call's kwargs to override the values - # set by hooks. - cdict = self._createdict - updates = [] - if not cdict.get("key"): - if not self.db_key: - self.db_key = "#%i" % self.dbid - updates.append("db_key") - elif self.db_key != cdict["key"]: - self.db_key = cdict["key"] - updates.append("db_key") - if cdict.get("interval") and self.interval != cdict["interval"]: - self.db_interval = cdict["interval"] - updates.append("db_interval") - if cdict.get("start_delay") and self.start_delay != cdict["start_delay"]: - self.db_start_delay = cdict["start_delay"] - updates.append("db_start_delay") - if cdict.get("repeats") and self.repeats != cdict["repeats"]: - self.db_repeats = cdict["repeats"] - updates.append("db_repeats") - if cdict.get("persistent") and self.persistent != cdict["persistent"]: - self.db_persistent = cdict["persistent"] - updates.append("db_persistent") - if updates: - self.save(update_fields=updates) - - if cdict.get("permissions"): - self.permissions.batch_add(*cdict["permissions"]) - if cdict.get("locks"): - self.locks.add(cdict["locks"]) - if cdict.get("tags"): - # this should be a list of tags, tuples (key, category) or (key, category, data) - self.tags.batch_add(*cdict["tags"]) - if cdict.get("attributes"): - # this should be tuples (key, val, ...) - self.attributes.batch_add(*cdict["attributes"]) - if cdict.get("nattributes"): - # this should be a dict of nattrname:value - for key, value in cdict["nattributes"]: - self.nattributes.add(key, value) - - if not cdict.get("autostart"): - # don't auto-start the script - return - - # auto-start script (default) - self.start() - - def at_script_creation(self): - """ - Only called once, by the create function. - - """ - pass - def is_valid(self): """ Is called to check if the script is valid to run at this time. diff --git a/evennia/utils/create.py b/evennia/utils/create.py index c5fb6f8416..36db7e5a60 100644 --- a/evennia/utils/create.py +++ b/evennia/utils/create.py @@ -101,6 +101,7 @@ def create_object(typeclass=None, key=None, location=None, home=None, locks = make_iter(locks) if locks is not None else None aliases = make_iter(aliases) if aliases is not None else None tags = make_iter(tags) if tags is not None else None + attributes = make_iter(attributes) if attributes is not None else None if isinstance(typeclass, basestring): @@ -177,7 +178,9 @@ def create_script(typeclass=None, key=None, obj=None, account=None, locks=None, created or if the `start` method must be called explicitly. report_to (Object): The object to return error messages to. desc (str): Optional description of script - + tags (list): List of tags or tuples (tag, category). + attributes (list): List if tuples (key, value) or (key, value, category) + (key, value, lockstring) or (key, value, lockstring, default_access). See evennia.scripts.manager for methods to manipulate existing scripts in the database. @@ -198,9 +201,9 @@ def create_script(typeclass=None, key=None, obj=None, account=None, locks=None, if key: kwarg["db_key"] = key if account: - kwarg["db_account"] = dbid_to_obj(account, _ScriptDB) + kwarg["db_account"] = dbid_to_obj(account, _AccountDB) if obj: - kwarg["db_obj"] = dbid_to_obj(obj, _ScriptDB) + kwarg["db_obj"] = dbid_to_obj(obj, _ObjectDB) if interval: kwarg["db_interval"] = interval if start_delay: @@ -211,6 +214,8 @@ def create_script(typeclass=None, key=None, obj=None, account=None, locks=None, kwarg["db_persistent"] = persistent if desc: kwarg["db_desc"] = desc + tags = make_iter(tags) if tags is not None else None + attributes = make_iter(attributes) if attributes is not None else None # create new instance new_script = typeclass(**kwarg) @@ -218,7 +223,8 @@ def create_script(typeclass=None, key=None, obj=None, account=None, locks=None, # store the call signature for the signal new_script._createdict = dict(key=key, obj=obj, account=account, locks=locks, interval=interval, start_delay=start_delay, repeats=repeats, persistent=persistent, - autostart=autostart, report_to=report_to) + autostart=autostart, report_to=report_to, desc=desc, + tags=tags, attributes=attributes) # this will trigger the save signal which in turn calls the # at_first_save hook on the typeclass, where the _createdict # can be used. diff --git a/evennia/utils/olc/olc_storage.py b/evennia/utils/olc/olc_storage.py index d64956f8a0..f96a79edb2 100644 --- a/evennia/utils/olc/olc_storage.py +++ b/evennia/utils/olc/olc_storage.py @@ -18,8 +18,9 @@ class PersistentPrototype(DefaultScript): """ This stores a single prototype """ - key = "persistent_prototype" - desc = "Stores a prototoype" + def at_script_creation(self): + self.key = "empty prototype" + self.desc = "A prototype" def store_prototype(caller, key, prototype, desc="", tags=None, locks="", delete=False): @@ -64,7 +65,7 @@ def store_prototype(caller, key, prototype, desc="", tags=None, locks="", delete if desc: stored_prototype.desc = desc if tags: - stored_prototype.tags.add(tags) + stored_prototype.tags.batch_add(*tags) if locks: stored_prototype.locks.add(locks) if prototype: @@ -95,12 +96,13 @@ def search_prototype(key=None, tags=None): be found. """ - matches = PersistentPrototype.objects.all() if tags: # exact match on tag(s) tags = make_iter(tags) - tag_categories = ("persistent_prototype" for _ in tags) - matches = matches.get_by_tag(tags, tag_categories) + tag_categories = ["persistent_prototype" for _ in tags] + matches = PersistentPrototype.objects.get_by_tag(tags, tag_categories) + else: + matches = PersistentPrototype.objects.all() if key: # partial match on key matches = matches.filter(db_key=key) or matches.filter(db_key__icontains=key) @@ -142,8 +144,8 @@ def get_prototype_list(caller, key=None, tags=None, show_non_use=False, show_non table = [] for i in range(len(prototypes[0])): - table.append([tup[i] for tup in prototypes]) - table = EvTable("Key", "Desc", "Use", "Edit", table, crop=True, width=78) + table.append([str(tup[i]) for tup in prototypes]) + table = EvTable("Key", "Desc", "Use", "Edit", table=table, crop=True, width=78) table.reformat_column(0, width=28) table.reformat_column(1, width=40) table.reformat_column(2, width=5) From 7ea6a58f196bc3cd5679c2bfa3cb3e1c8267b0eb Mon Sep 17 00:00:00 2001 From: Griatch Date: Sat, 3 Mar 2018 14:57:55 +0100 Subject: [PATCH 039/208] Refactor, include readonly prototypes --- evennia/utils/olc/olc_storage.py | 148 +++++++++++++++++++++++++++---- 1 file changed, 131 insertions(+), 17 deletions(-) diff --git a/evennia/utils/olc/olc_storage.py b/evennia/utils/olc/olc_storage.py index f96a79edb2..a10c0d508b 100644 --- a/evennia/utils/olc/olc_storage.py +++ b/evennia/utils/olc/olc_storage.py @@ -6,13 +6,41 @@ available in a repository for buildiers to use. Each prototype is stored in a Script so that it can be tagged for quick sorting/finding and locked for limiting access. +This system also takes into consideration prototypes defined and stored in modules. +Such prototypes are considered 'read-only' to the system and can only be modified +in code. To replace a default prototype, add the same-name prototype in a +custom module read later in the settings.PROTOTYPE_MODULES list. To remove a default +prototype, override its name with an empty dict. + """ +from django.conf import settings from evennia.scripts.scripts import DefaultScript from evennia.utils.create import create_script -from evennia.utils.utils import make_iter +from evennia.utils.utils import make_iter, all_from_module from evennia.utils.evtable import EvTable +# prepare the available prototypes defined in modules + +_READONLY_PROTOTYPES = {} +_READONLY_PROTOTYPE_MODULES = {} + +for mod in settings.PROTOTYPE_MODULES: + # to remove a default prototype, override it with an empty dict. + # internally we store as (key, desc, locks, tags, prototype_dict) + prots = [(key, prot) for key, prot in all_from_module(mod).items() + if prot and isinstance(prot, dict)] + _READONLY_PROTOTYPES.update( + {key.lower(): + (key.lower(), + prot['prototype_desc'] if 'prototype_desc' in prot else mod, + prot['prototype_lock'] if 'prototype_lock' in prot else "use:all()", + set(make_iter( + prot['prototype_tags']) if 'prototype_tags' in prot else ["base-prototype"]), + prot) + for key, prot in prots}) + _READONLY_PROTOTYPE_MODULES.update({tup[0]: mod for tup in prots}) + class PersistentPrototype(DefaultScript): """ @@ -46,17 +74,25 @@ def store_prototype(caller, key, prototype, desc="", tags=None, locks="", delete Raises: PermissionError: If edit lock was not passed by caller. + """ + key_orig = key key = key.lower() locks = locks if locks else "use:all();edit:id({}) or edit:perm(Admin)".format(caller.id) tags = [(tag, "persistent_prototype") for tag in make_iter(tags)] + if key in _READONLY_PROTOTYPES: + mod = _READONLY_PROTOTYPE_MODULES.get(key, "N/A") + raise PermissionError("{} is a read-only prototype " + "(defined as code in {}).".format(key_orig, mod)) + stored_prototype = PersistentPrototype.objects.filter(db_key=key) if stored_prototype: stored_prototype = stored_prototype[0] if not stored_prototype.access(caller, 'edit'): - PermissionError("{} does not have permission to edit prototype {}".format(caller, key)) + raise PermissionError("{} does not have permission to " + "edit prototype {}".format(caller, key)) if delete: stored_prototype.delete() @@ -77,9 +113,9 @@ def store_prototype(caller, key, prototype, desc="", tags=None, locks="", delete return stored_prototype -def search_prototype(key=None, tags=None): +def search_persistent_prototype(key=None, tags=None): """ - Find prototypes based on key and/or tags. + Find persistent (database-stored) prototypes based on key and/or tags. Kwargs: key (str): An exact or partial key to query for. @@ -87,13 +123,10 @@ def search_prototype(key=None, tags=None): will always be applied with the 'persistent_protototype' tag category. Return: - matches (queryset): All found PersistentPrototypes. This will - be all prototypes if no arguments are given. + matches (queryset): All found PersistentPrototypes Note: - This will use the tags to make a subselection before attempting - to match on the key. So if key/tags don't match up nothing will - be found. + This will not include read-only prototypes defined in modules. """ if tags: @@ -109,6 +142,68 @@ def search_prototype(key=None, tags=None): return matches +def search_readonly_prototype(key=None, tags=None): + """ + Find read-only prototypes, defined in modules. + + Kwargs: + key (str): An exact or partial key to query for. + tags (str or list): Tag key to query for. + + Return: + matches (list): List of prototype tuples that includes + prototype metadata, on the form + `(key, desc, lockstring, taglist, prototypedict)` + + """ + matches = [] + if tags: + # use tags to limit selection + tagset = set(tags) + matches = {key: tup for key, tup in _READONLY_PROTOTYPES.items() + if tagset.intersection(tup[3])} + else: + matches = _READONLY_PROTOTYPES + + if key: + if key in matches: + # exact match + return matches[key] + else: + # fuzzy matching + return [tup for pkey, tup in matches.items() if key in pkey] + return matches + + +def search_prototype(key=None, tags=None): + """ + Find prototypes based on key and/or tags. + + Kwargs: + key (str): An exact or partial key to query for. + tags (str or list): Tag key or keys to query for. These + will always be applied with the 'persistent_protototype' + tag category. + Return: + matches (list): All found prototype dicts. + + Note: + The available prototypes is a combination of those supplied in + PROTOTYPE_MODULES and those stored from in-game. For the latter, + this will use the tags to make a subselection before attempting + to match on the key. So if key/tags don't match up nothing will + be found. + + """ + matches = [] + if key and key in _READONLY_PROTOTYPES: + matches.append(_READONLY_PROTOTYPES[key][3]) + else: + matches.extend([prot.attributes.get("prototype") + for prot in search_persistent_prototype(key, tags)]) + return matches + + def get_prototype_list(caller, key=None, tags=None, show_non_use=False, show_non_edit=True): """ Collate a list of found prototypes based on search criteria and access. @@ -124,20 +219,39 @@ def get_prototype_list(caller, key=None, tags=None, show_non_use=False, show_non if no prototypes were found. """ - prototypes = search_prototype(key, tags) + # handle read-only prototypes separately + if key and key in _READONLY_PROTOTYPES: + readonly_prototypes = _READONLY_PROTOTYPES[key] + else: + readonly_prototypes = _READONLY_PROTOTYPES.values() + + # get use-permissions of readonly attributes (edit is always False) + readonly_prototypes = [ + (tup[0], + tup[1], + ("{}/N".format('Y' + if caller.locks.check_lockstring(caller, tup[2], access_type='use') else 'N')), + ",".join(tup[3])) for tup in readonly_prototypes] + + # next, handle db-stored prototypes + prototypes = search_persistent_prototype(key, tags) if not prototypes: return None - # gather access permissions as (key, desc, can_use, can_edit) + # gather access permissions as (key, desc, tags, can_use, can_edit) prototypes = [(prototype.key, prototype.desc, - prototype.access(caller, "use"), prototype.access(caller, "edit")) + "{}/{}".format('Y' if prototype.access(caller, "use") else 'N', + 'Y' if prototype.access(caller, "edit") else 'N'), + ",".join(prototype.tags.get(category="persistent_prototype"))) for prototype in prototypes] + prototypes = prototypes + readonly_prototypes + if not show_non_use: - prototypes = [tup for tup in prototypes if tup[2]] + prototypes = [tup for tup in sorted(prototypes, key=lambda o: o[0]) if tup[2]] if not show_non_edit: - prototypes = [tup for tup in prototypes if tup[3]] + prototypes = [tup for tup in sorted(prototypes, key=lambda o: o[0]) if tup[3]] if not prototypes: return None @@ -145,9 +259,9 @@ def get_prototype_list(caller, key=None, tags=None, show_non_use=False, show_non table = [] for i in range(len(prototypes[0])): table.append([str(tup[i]) for tup in prototypes]) - table = EvTable("Key", "Desc", "Use", "Edit", table=table, crop=True, width=78) + table = EvTable("Key", "Desc", "Use/Edit", "Tags", table=table, crop=True, width=78) table.reformat_column(0, width=28) table.reformat_column(1, width=40) - table.reformat_column(2, width=5) - table.reformat_column(3, width=5) + table.reformat_column(2, width=11, align='r') + table.reformat_column(3, width=20) return table From f269e80fdca8fcf398b3d35a09ca2ce84a00b708 Mon Sep 17 00:00:00 2001 From: Griatch Date: Sat, 3 Mar 2018 15:19:18 +0100 Subject: [PATCH 040/208] Use namedtuples for internal meta info --- evennia/utils/olc/olc_storage.py | 37 +++++++++++++++++--------------- 1 file changed, 20 insertions(+), 17 deletions(-) diff --git a/evennia/utils/olc/olc_storage.py b/evennia/utils/olc/olc_storage.py index a10c0d508b..7c4adad5c4 100644 --- a/evennia/utils/olc/olc_storage.py +++ b/evennia/utils/olc/olc_storage.py @@ -14,6 +14,7 @@ prototype, override its name with an empty dict. """ +from collections import namedtuple from django.conf import settings from evennia.scripts.scripts import DefaultScript from evennia.utils.create import create_script @@ -25,19 +26,22 @@ from evennia.utils.evtable import EvTable _READONLY_PROTOTYPES = {} _READONLY_PROTOTYPE_MODULES = {} +# storage of meta info about the prototype +MetaProto = namedtuple('MetaProto', ['key', 'desc', 'locks', 'tags', 'prototype']) + for mod in settings.PROTOTYPE_MODULES: # to remove a default prototype, override it with an empty dict. # internally we store as (key, desc, locks, tags, prototype_dict) prots = [(key, prot) for key, prot in all_from_module(mod).items() if prot and isinstance(prot, dict)] _READONLY_PROTOTYPES.update( - {key.lower(): - (key.lower(), - prot['prototype_desc'] if 'prototype_desc' in prot else mod, - prot['prototype_lock'] if 'prototype_lock' in prot else "use:all()", - set(make_iter( - prot['prototype_tags']) if 'prototype_tags' in prot else ["base-prototype"]), - prot) + {key.lower(): MetaProto( + key.lower(), + prot['prototype_desc'] if 'prototype_desc' in prot else mod, + prot['prototype_lock'] if 'prototype_lock' in prot else "use:all()", + set(make_iter( + prot['prototype_tags']) if 'prototype_tags' in prot else ["base-prototype"]), + prot) for key, prot in prots}) _READONLY_PROTOTYPE_MODULES.update({tup[0]: mod for tup in prots}) @@ -151,17 +155,16 @@ def search_readonly_prototype(key=None, tags=None): tags (str or list): Tag key to query for. Return: - matches (list): List of prototype tuples that includes - prototype metadata, on the form - `(key, desc, lockstring, taglist, prototypedict)` + matches (list): List of MetaProto tuples that includes + prototype metadata, """ matches = [] if tags: # use tags to limit selection tagset = set(tags) - matches = {key: tup for key, tup in _READONLY_PROTOTYPES.items() - if tagset.intersection(tup[3])} + matches = {key: metaproto for key, metaproto in _READONLY_PROTOTYPES.items() + if tagset.intersection(metaproto.tags)} else: matches = _READONLY_PROTOTYPES @@ -171,7 +174,7 @@ def search_readonly_prototype(key=None, tags=None): return matches[key] else: # fuzzy matching - return [tup for pkey, tup in matches.items() if key in pkey] + return [metaproto for pkey, metaproto in matches.items() if key in pkey] return matches @@ -227,11 +230,11 @@ def get_prototype_list(caller, key=None, tags=None, show_non_use=False, show_non # get use-permissions of readonly attributes (edit is always False) readonly_prototypes = [ - (tup[0], - tup[1], + (tup.key, + tup.desc, ("{}/N".format('Y' - if caller.locks.check_lockstring(caller, tup[2], access_type='use') else 'N')), - ",".join(tup[3])) for tup in readonly_prototypes] + if caller.locks.check_lockstring(caller, tup.locks, access_type='use') else 'N')), + ",".join(tup.tags)) for tup in readonly_prototypes] # next, handle db-stored prototypes prototypes = search_persistent_prototype(key, tags) From 98888d636a0630d31e88ed447b766e945a7fab39 Mon Sep 17 00:00:00 2001 From: Griatch Date: Sat, 3 Mar 2018 16:24:17 +0100 Subject: [PATCH 041/208] Use readonly-search for prototypes --- evennia/utils/olc/olc_storage.py | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/evennia/utils/olc/olc_storage.py b/evennia/utils/olc/olc_storage.py index 7c4adad5c4..113d0296cb 100644 --- a/evennia/utils/olc/olc_storage.py +++ b/evennia/utils/olc/olc_storage.py @@ -159,7 +159,7 @@ def search_readonly_prototype(key=None, tags=None): prototype metadata, """ - matches = [] + matches = {} if tags: # use tags to limit selection tagset = set(tags) @@ -171,11 +171,12 @@ def search_readonly_prototype(key=None, tags=None): if key: if key in matches: # exact match - return matches[key] + return [matches[key]] else: # fuzzy matching return [metaproto for pkey, metaproto in matches.items() if key in pkey] - return matches + else: + return [match for match in matches.values()] def search_prototype(key=None, tags=None): @@ -223,10 +224,7 @@ def get_prototype_list(caller, key=None, tags=None, show_non_use=False, show_non """ # handle read-only prototypes separately - if key and key in _READONLY_PROTOTYPES: - readonly_prototypes = _READONLY_PROTOTYPES[key] - else: - readonly_prototypes = _READONLY_PROTOTYPES.values() + readonly_prototypes = search_readonly_prototype(key, tags) # get use-permissions of readonly attributes (edit is always False) readonly_prototypes = [ @@ -239,9 +237,6 @@ def get_prototype_list(caller, key=None, tags=None, show_non_use=False, show_non # next, handle db-stored prototypes prototypes = search_persistent_prototype(key, tags) - if not prototypes: - return None - # gather access permissions as (key, desc, tags, can_use, can_edit) prototypes = [(prototype.key, prototype.desc, "{}/{}".format('Y' if prototype.access(caller, "use") else 'N', @@ -251,6 +246,9 @@ def get_prototype_list(caller, key=None, tags=None, show_non_use=False, show_non prototypes = prototypes + readonly_prototypes + if not prototypes: + return None + if not show_non_use: prototypes = [tup for tup in sorted(prototypes, key=lambda o: o[0]) if tup[2]] if not show_non_edit: From 2b8b0b1b699976b026bf9d3ddcadce824a76e905 Mon Sep 17 00:00:00 2001 From: Griatch Date: Sat, 3 Mar 2018 18:29:37 +0100 Subject: [PATCH 042/208] Start expanding spawn command for prot-storage --- evennia/commands/default/building.py | 33 +++- evennia/utils/olc/olc_storage.py | 17 +- evennia/utils/spawner.py | 276 ++++++++++++++++++++++++++- 3 files changed, 310 insertions(+), 16 deletions(-) diff --git a/evennia/commands/default/building.py b/evennia/commands/default/building.py index 853ca6b88f..6cbef476d4 100644 --- a/evennia/commands/default/building.py +++ b/evennia/commands/default/building.py @@ -12,7 +12,7 @@ from evennia.commands.cmdhandler import get_and_merge_cmdsets from evennia.utils import create, utils, search from evennia.utils.utils import inherits_from, class_from_module from evennia.utils.eveditor import EvEditor -from evennia.utils.spawner import spawn +from evennia.utils.spawner import spawn, search_prototype, list_prototypes from evennia.utils.ansi import raw COMMAND_DEFAULT_CLASS = class_from_module(settings.COMMAND_DEFAULT_CLASS) @@ -2731,17 +2731,29 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): spawn objects from prototype Usage: - @spawn - @spawn[/switch] - @spawn[/switch] {prototype dictionary} + @spawn[/noloc] + @spawn[/noloc] - Switch: + @spawn/search [query] + @spawn/list [tag, tag] + @spawn/show + + @spawn/save [;desc[;tag,tag,..[;lockstring]]] + @spawn/menu + + Switches: noloc - allow location to be None if not specified explicitly. Otherwise, location will default to caller's current location. + search - search prototype by name or tags. + list - list available prototypes, optionally limit by tags. + show - inspect prototype by key. + save - save a prototype to the database. It will be listable by /list. + menu - manipulate prototype in a menu interface. Example: @spawn GOBLIN @spawn {"key":"goblin", "typeclass":"monster.Monster", "location":"#2"} + @spawn/save {"key": "grunt", prototype: "goblin"};;mobs;edit:all() Dictionary keys: |wprototype |n - name of parent prototype to use. Can be a list for @@ -2760,12 +2772,16 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): The available prototypes are defined globally in modules set in settings.PROTOTYPE_MODULES. If @spawn is used without arguments it displays a list of available prototypes. + """ key = "@spawn" locks = "cmd:perm(spawn) or perm(Builder)" help_category = "Building" + def parser(self): + super(CmdSpawn, self).parser() + def func(self): """Implements the spawner""" @@ -2774,6 +2790,13 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): prots = ", ".join(sorted(prototypes.keys())) return "\nAvailable prototypes (case sensitive): %s" % ( "\n" + utils.fill(prots) if prots else "None") + caller = self.caller + + if not self.args: + ncount = len(search_prototype()) + caller.msg("Usage: @spawn or {key: value, ...}" + "\n ({} existing prototypes. Use /list to inspect)".format(ncount)) + return prototypes = spawn(return_prototypes=True) if not self.args: diff --git a/evennia/utils/olc/olc_storage.py b/evennia/utils/olc/olc_storage.py index 113d0296cb..cda1e6d0eb 100644 --- a/evennia/utils/olc/olc_storage.py +++ b/evennia/utils/olc/olc_storage.py @@ -228,11 +228,12 @@ def get_prototype_list(caller, key=None, tags=None, show_non_use=False, show_non # get use-permissions of readonly attributes (edit is always False) readonly_prototypes = [ - (tup.key, - tup.desc, + (metaproto.key, + metaproto.desc, ("{}/N".format('Y' - if caller.locks.check_lockstring(caller, tup.locks, access_type='use') else 'N')), - ",".join(tup.tags)) for tup in readonly_prototypes] + if caller.locks.check_lockstring(caller, metaproto.locks, access_type='use') else 'N')), + ",".join(metaproto.tags)) + for metaproto in sorted(readonly_prototypes, key=lambda o: o.key)] # next, handle db-stored prototypes prototypes = search_persistent_prototype(key, tags) @@ -242,7 +243,7 @@ def get_prototype_list(caller, key=None, tags=None, show_non_use=False, show_non "{}/{}".format('Y' if prototype.access(caller, "use") else 'N', 'Y' if prototype.access(caller, "edit") else 'N'), ",".join(prototype.tags.get(category="persistent_prototype"))) - for prototype in prototypes] + for prototype in sorted(prototypes, key=lambda o: o.key)] prototypes = prototypes + readonly_prototypes @@ -250,16 +251,16 @@ def get_prototype_list(caller, key=None, tags=None, show_non_use=False, show_non return None if not show_non_use: - prototypes = [tup for tup in sorted(prototypes, key=lambda o: o[0]) if tup[2]] + prototypes = [metaproto for metaproto in prototypes if metaproto[2].split("/", 1)[0] == 'Y'] if not show_non_edit: - prototypes = [tup for tup in sorted(prototypes, key=lambda o: o[0]) if tup[3]] + prototypes = [metaproto for metaproto in prototypes if metaproto[2].split("/", 1)[1] == 'Y'] if not prototypes: return None table = [] for i in range(len(prototypes[0])): - table.append([str(tup[i]) for tup in prototypes]) + table.append([str(metaproto[i]) for metaproto in prototypes]) table = EvTable("Key", "Desc", "Use/Edit", "Tags", table=table, crop=True, width=78) table.reformat_column(0, width=28) table.reformat_column(1, width=40) diff --git a/evennia/utils/spawner.py b/evennia/utils/spawner.py index 86b24e5e4f..d1d4bea920 100644 --- a/evennia/utils/spawner.py +++ b/evennia/utils/spawner.py @@ -86,6 +86,21 @@ The *goblin archwizard* will have some different attacks, but will otherwise have the same spells as a *goblin wizard* who in turn shares many traits with a normal *goblin*. + +Storage mechanism: + +This sets up a central storage for prototypes. The idea is to make these +available in a repository for buildiers to use. Each prototype is stored +in a Script so that it can be tagged for quick sorting/finding and locked for limiting +access. + +This system also takes into consideration prototypes defined and stored in modules. +Such prototypes are considered 'read-only' to the system and can only be modified +in code. To replace a default prototype, add the same-name prototype in a +custom module read later in the settings.PROTOTYPE_MODULES list. To remove a default +prototype, override its name with an empty dict. + + """ from __future__ import print_function @@ -96,7 +111,35 @@ import evennia from evennia.objects.models import ObjectDB from evennia.utils.utils import make_iter, all_from_module, dbid_to_obj +from collections import namedtuple +from evennia.scripts.scripts import DefaultScript +from evennia.utils.create import create_script +from evennia.utils.evtable import EvTable + + _CREATE_OBJECT_KWARGS = ("key", "location", "home", "destination") +_READONLY_PROTOTYPES = {} +_READONLY_PROTOTYPE_MODULES = {} + + +# storage of meta info about the prototype +MetaProto = namedtuple('MetaProto', ['key', 'desc', 'locks', 'tags', 'prototype']) + +for mod in settings.PROTOTYPE_MODULES: + # to remove a default prototype, override it with an empty dict. + # internally we store as (key, desc, locks, tags, prototype_dict) + prots = [(key, prot) for key, prot in all_from_module(mod).items() + if prot and isinstance(prot, dict)] + _READONLY_PROTOTYPES.update( + {key.lower(): MetaProto( + key.lower(), + prot['prototype_desc'] if 'prototype_desc' in prot else mod, + prot['prototype_lock'] if 'prototype_lock' in prot else "use:all()", + set(make_iter( + prot['prototype_tags']) if 'prototype_tags' in prot else ["base-prototype"]), + prot) + for key, prot in prots}) + _READONLY_PROTOTYPE_MODULES.update({tup[0]: mod for tup in prots}) def _handle_dbref(inp): @@ -119,7 +162,8 @@ def _validate_prototype(key, prototype, protparents, visited): raise RuntimeError("%s tries to prototype itself." % key or prototype) protparent = protparents.get(protstring) if not protparent: - raise RuntimeError("%s's prototype '%s' was not found." % (key or prototype, protstring)) + raise RuntimeError( + "%s's prototype '%s' was not found." % (key or prototype, protstring)) _validate_prototype(protstring, protparent, protparents, visited) @@ -303,9 +347,235 @@ def spawn(*prototypes, **kwargs): return _batch_create_object(*objsparams) -if __name__ == "__main__": - # testing +# Prototype storage mechanisms + +class PersistentPrototype(DefaultScript): + """ + This stores a single prototype + """ + def at_script_creation(self): + self.key = "empty prototype" + self.desc = "A prototype" + + +def store_prototype(caller, key, prototype, desc="", tags=None, locks="", delete=False): + """ + Store a prototype persistently. + + Args: + caller (Account or Object): Caller aiming to store prototype. At this point + the caller should have permission to 'add' new prototypes, but to edit + an existing prototype, the 'edit' lock must be passed on that prototype. + key (str): Name of prototype to store. + prototype (dict): Prototype dict. + desc (str, optional): Description of prototype, to use in listing. + tags (list, optional): Tag-strings to apply to prototype. These are always + applied with the 'persistent_prototype' category. + locks (str, optional): Locks to apply to this prototype. Used locks + are 'use' and 'edit' + delete (bool, optional): Delete an existing prototype identified by 'key'. + This requires `caller` to pass the 'edit' lock of the prototype. + Returns: + stored (StoredPrototype or None): The resulting prototype (new or edited), + or None if deleting. + Raises: + PermissionError: If edit lock was not passed by caller. + + + """ + key_orig = key + key = key.lower() + locks = locks if locks else "use:all();edit:id({}) or edit:perm(Admin)".format(caller.id) + tags = [(tag, "persistent_prototype") for tag in make_iter(tags)] + + if key in _READONLY_PROTOTYPES: + mod = _READONLY_PROTOTYPE_MODULES.get(key, "N/A") + raise PermissionError("{} is a read-only prototype " + "(defined as code in {}).".format(key_orig, mod)) + + stored_prototype = PersistentPrototype.objects.filter(db_key=key) + + if stored_prototype: + stored_prototype = stored_prototype[0] + if not stored_prototype.access(caller, 'edit'): + raise PermissionError("{} does not have permission to " + "edit prototype {}".format(caller, key)) + + if delete: + stored_prototype.delete() + return + + if desc: + stored_prototype.desc = desc + if tags: + stored_prototype.tags.batch_add(*tags) + if locks: + stored_prototype.locks.add(locks) + if prototype: + stored_prototype.attributes.add("prototype", prototype) + else: + stored_prototype = create_script( + PersistentPrototype, key=key, desc=desc, persistent=True, + locks=locks, tags=tags, attributes=[("prototype", prototype)]) + return stored_prototype + + +def search_persistent_prototype(key=None, tags=None): + """ + Find persistent (database-stored) prototypes based on key and/or tags. + + Kwargs: + key (str): An exact or partial key to query for. + tags (str or list): Tag key or keys to query for. These + will always be applied with the 'persistent_protototype' + tag category. + Return: + matches (queryset): All found PersistentPrototypes + + Note: + This will not include read-only prototypes defined in modules. + + """ + if tags: + # exact match on tag(s) + tags = make_iter(tags) + tag_categories = ["persistent_prototype" for _ in tags] + matches = PersistentPrototype.objects.get_by_tag(tags, tag_categories) + else: + matches = PersistentPrototype.objects.all() + if key: + # partial match on key + matches = matches.filter(db_key=key) or matches.filter(db_key__icontains=key) + return matches + + +def search_readonly_prototype(key=None, tags=None): + """ + Find read-only prototypes, defined in modules. + + Kwargs: + key (str): An exact or partial key to query for. + tags (str or list): Tag key to query for. + + Return: + matches (list): List of MetaProto tuples that includes + prototype metadata, + + """ + matches = {} + if tags: + # use tags to limit selection + tagset = set(tags) + matches = {key: metaproto for key, metaproto in _READONLY_PROTOTYPES.items() + if tagset.intersection(metaproto.tags)} + else: + matches = _READONLY_PROTOTYPES + + if key: + if key in matches: + # exact match + return [matches[key]] + else: + # fuzzy matching + return [metaproto for pkey, metaproto in matches.items() if key in pkey] + else: + return [match for match in matches.values()] + + +def search_prototype(key=None, tags=None): + """ + Find prototypes based on key and/or tags. + + Kwargs: + key (str): An exact or partial key to query for. + tags (str or list): Tag key or keys to query for. These + will always be applied with the 'persistent_protototype' + tag category. + Return: + matches (list): All found prototype dicts. + + Note: + The available prototypes is a combination of those supplied in + PROTOTYPE_MODULES and those stored from in-game. For the latter, + this will use the tags to make a subselection before attempting + to match on the key. So if key/tags don't match up nothing will + be found. + + """ + matches = [] + if key and key in _READONLY_PROTOTYPES: + matches.append(_READONLY_PROTOTYPES[key][3]) + else: + matches.extend([prot.attributes.get("prototype") + for prot in search_persistent_prototype(key, tags)]) + return matches + + +def list_prototypes(caller, key=None, tags=None, show_non_use=False, show_non_edit=True): + """ + Collate a list of found prototypes based on search criteria and access. + + Args: + caller (Account or Object): The object requesting the list. + key (str, optional): Exact or partial key to query for. + tags (str or list, optional): Tag key or keys to query for. + show_non_use (bool, optional): Show also prototypes the caller may not use. + show_non_edit (bool, optional): Show also prototypes the caller may not edit. + Returns: + table (EvTable or None): An EvTable representation of the prototypes. None + if no prototypes were found. + + """ + # handle read-only prototypes separately + readonly_prototypes = search_readonly_prototype(key, tags) + + # get use-permissions of readonly attributes (edit is always False) + readonly_prototypes = [ + (metaproto.key, + metaproto.desc, + ("{}/N".format('Y' + if caller.locks.check_lockstring(caller, metaproto.locks, access_type='use') else 'N')), + ",".join(metaproto.tags)) + for metaproto in sorted(readonly_prototypes, key=lambda o: o.key)] + + # next, handle db-stored prototypes + prototypes = search_persistent_prototype(key, tags) + + # gather access permissions as (key, desc, tags, can_use, can_edit) + prototypes = [(prototype.key, prototype.desc, + "{}/{}".format('Y' if prototype.access(caller, "use") else 'N', + 'Y' if prototype.access(caller, "edit") else 'N'), + ",".join(prototype.tags.get(category="persistent_prototype"))) + for prototype in sorted(prototypes, key=lambda o: o.key)] + + prototypes = prototypes + readonly_prototypes + + if not prototypes: + return None + + if not show_non_use: + prototypes = [metaproto for metaproto in prototypes if metaproto[2].split("/", 1)[0] == 'Y'] + if not show_non_edit: + prototypes = [metaproto for metaproto in prototypes if metaproto[2].split("/", 1)[1] == 'Y'] + + if not prototypes: + return None + + table = [] + for i in range(len(prototypes[0])): + table.append([str(metaproto[i]) for metaproto in prototypes]) + table = EvTable("Key", "Desc", "Use/Edit", "Tags", table=table, crop=True, width=78) + table.reformat_column(0, width=28) + table.reformat_column(1, width=40) + table.reformat_column(2, width=11, align='r') + table.reformat_column(3, width=20) + return table + + +# Testing + +if __name__ == "__main__": protparents = { "NOBODY": {}, # "INFINITE" : { From e95387a7a98051beec2ae74995981a832f32946a Mon Sep 17 00:00:00 2001 From: Griatch Date: Sat, 3 Mar 2018 19:55:37 +0100 Subject: [PATCH 043/208] Continue working with new spawn additions --- evennia/commands/default/building.py | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/evennia/commands/default/building.py b/evennia/commands/default/building.py index 6cbef476d4..9bc7915497 100644 --- a/evennia/commands/default/building.py +++ b/evennia/commands/default/building.py @@ -12,6 +12,7 @@ from evennia.commands.cmdhandler import get_and_merge_cmdsets from evennia.utils import create, utils, search from evennia.utils.utils import inherits_from, class_from_module from evennia.utils.eveditor import EvEditor +from evennia.utils.evmore import EvMore from evennia.utils.spawner import spawn, search_prototype, list_prototypes from evennia.utils.ansi import raw @@ -2736,7 +2737,7 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): @spawn/search [query] @spawn/list [tag, tag] - @spawn/show + @spawn/show [] @spawn/save [;desc[;tag,tag,..[;lockstring]]] @spawn/menu @@ -2746,7 +2747,7 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): location will default to caller's current location. search - search prototype by name or tags. list - list available prototypes, optionally limit by tags. - show - inspect prototype by key. + show - inspect prototype by key. If not given, acts like list. save - save a prototype to the database. It will be listable by /list. menu - manipulate prototype in a menu interface. @@ -2792,12 +2793,28 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): "\n" + utils.fill(prots) if prots else "None") caller = self.caller + + if 'show' in self.switches: + # the argument is a key in this case (may be a partial key) + if not self.args: + self.switches.append('list') + else: + EvMore(caller, unicode(list_prototypes(key=self.args), exit_on_lastpage=True)) + return + + if 'list' in self.switches: + # for list, all optional arguments are tags + EvMore(caller, unicode(list_prototypes(tags=self.lhslist)), exit_on_lastpage=True) + return + if not self.args: ncount = len(search_prototype()) caller.msg("Usage: @spawn or {key: value, ...}" "\n ({} existing prototypes. Use /list to inspect)".format(ncount)) return + + prototypes = spawn(return_prototypes=True) if not self.args: string = "Usage: @spawn {key:value, key, value, ... }" From ddd56cdeb312768197676fddfbbaa474b11ebccb Mon Sep 17 00:00:00 2001 From: Griatch Date: Sun, 4 Mar 2018 11:39:55 +0100 Subject: [PATCH 044/208] First version of expanded spawn command with storage --- evennia/commands/default/building.py | 175 ++++++++++++---- evennia/commands/default/muxcommand.py | 2 +- evennia/utils/evmore.py | 30 ++- evennia/utils/olc/olc_storage.py | 269 ------------------------- evennia/utils/spawner.py | 33 ++- 5 files changed, 181 insertions(+), 328 deletions(-) delete mode 100644 evennia/utils/olc/olc_storage.py diff --git a/evennia/commands/default/building.py b/evennia/commands/default/building.py index 9bc7915497..dfa78ea074 100644 --- a/evennia/commands/default/building.py +++ b/evennia/commands/default/building.py @@ -13,7 +13,7 @@ from evennia.utils import create, utils, search from evennia.utils.utils import inherits_from, class_from_module from evennia.utils.eveditor import EvEditor from evennia.utils.evmore import EvMore -from evennia.utils.spawner import spawn, search_prototype, list_prototypes +from evennia.utils.spawner import spawn, search_prototype, list_prototypes, store_prototype from evennia.utils.ansi import raw COMMAND_DEFAULT_CLASS = class_from_module(settings.COMMAND_DEFAULT_CLASS) @@ -2735,11 +2735,11 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): @spawn[/noloc] @spawn[/noloc] - @spawn/search [query] + @spawn/search [key][;tag[,tag]] @spawn/list [tag, tag] @spawn/show [] - @spawn/save [;desc[;tag,tag,..[;lockstring]]] + @spawn/save [;desc[;tag,tag[,...][;lockstring]]] = @spawn/menu Switches: @@ -2786,73 +2786,164 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): def func(self): """Implements the spawner""" - def _show_prototypes(prototypes): - """Helper to show a list of available prototypes""" - prots = ", ".join(sorted(prototypes.keys())) - return "\nAvailable prototypes (case sensitive): %s" % ( - "\n" + utils.fill(prots) if prots else "None") + def _parse_prototype(inp, allow_key=False): + try: + # make use of _convert_from_string from the SetAttribute command + prototype = _convert_from_string(self, inp) + except SyntaxError: + # this means literal_eval tried to parse a faulty string + string = ("|RCritical Python syntax error in argument. Only primitive " + "Python structures are allowed. \nYou also need to use correct " + "Python syntax. Remember especially to put quotes around all " + "strings inside lists and dicts.|n") + self.caller.msg(string) + return None + if isinstance(prototype, dict): + # an actual prototype. We need to make sure it's safe. Don't allow exec + if "exec" in prototype and not self.caller.check_permstring("Developer"): + self.caller.msg("Spawn aborted: You don't have access to " + "use the 'exec' prototype key.") + return None + elif isinstance(prototype, basestring): + # a prototype key + if allow_key: + return prototype + else: + self.caller.msg("The prototype must be defined as a Python dictionary.") + else: + caller.msg("The prototype must be given either as a Python dictionary or a key") + return None + + + def _search_show_prototype(query): + # prototype detail + strings = [] + metaprots = search_prototype(key=query, return_meta=True) + if metaprots: + for metaprot in metaprots: + header = ( + "|cprototype key:|n {}, |ctags:|n {}, |clocks:|n {} \n" + "|cdesc:|n {} \n|cprototype:|n ".format( + metaprot.key, ", ".join(metaprot.tags), + metaprot.locks, metaprot.desc)) + prototype = ("{{\n {} \n}}".format("\n ".join("{!r}: {!r},".format(key, value) + for key, value in + sorted(metaprot.prototype.items())).rstrip(","))) + strings.append(header + prototype) + return "\n".join(strings) + else: + return False + caller = self.caller + if 'search' in self.switches: + # query for a key match + if not self.args: + self.switches.append("list") + else: + key, tags = self.args.strip(), None + if ';' in self.args: + key, tags = (part.strip().lower() for part in self.args.split(";", 1)) + tags = [tag.strip() for tag in tags.split(",")] if tags else None + EvMore(caller, unicode(list_prototypes(caller, key=key, tags=tags)), + exit_on_lastpage=True) + return if 'show' in self.switches: # the argument is a key in this case (may be a partial key) if not self.args: self.switches.append('list') else: - EvMore(caller, unicode(list_prototypes(key=self.args), exit_on_lastpage=True)) + matchstring = _search_show_prototype(self.args) + if matchstring: + caller.msg(matchstring) + else: + caller.msg("No prototype '{}' was found.".format(self.args)) return if 'list' in self.switches: # for list, all optional arguments are tags - EvMore(caller, unicode(list_prototypes(tags=self.lhslist)), exit_on_lastpage=True) + EvMore(caller, unicode(list_prototypes(caller, + tags=self.lhslist)), exit_on_lastpage=True) + return + + if 'save' in self.switches: + if not self.args or not self.rhs: + caller.msg("Usage: @spawn/save [;desc[;tag,tag[,...][;lockstring]]] = ") + return + + # handle lhs + parts = self.rhs.split(";", 3) + key, desc, tags, lockstring = "", "", [], "" + nparts = len(parts) + if nparts == 1: + key = parts.strip() + elif nparts == 2: + key, desc = (part.strip() for part in parts) + elif nparts == 3: + key, desc, tags = (part.strip() for part in parts) + tags = [tag.strip().lower() for tag in tags.split(",")] + else: + # lockstrings can itself contain ; + key, desc, tags, lockstring = (part.strip() for part in parts) + tags = [tag.strip().lower() for tag in tags.split(",")] + + # handle rhs: + prototype = _parse_prototype(caller, self.rhs) + if not prototype: + return + + # check for existing prototype + matchstring = _search_show_prototype(key) + if matchstring: + caller.msg("|yExisting saved prototype found:|n\n{}".format(matchstring)) + answer = ("Do you want to replace the existing prototype? Y/[N]") + if not answer.lower() not in ["y", "yes"]: + caller.msg("Save cancelled.") + + # all seems ok. Try to save. + try: + store_prototype(caller, key, prototype, desc=desc, tags=tags, locks=lockstring) + except PermissionError as err: + caller.msg("|rError saving:|R {}|n".format(err)) + return + caller.msg("Saved prototype:") + caller.execute_cmd("spawn/show {}".format(key)) return if not self.args: ncount = len(search_prototype()) - caller.msg("Usage: @spawn or {key: value, ...}" + caller.msg("Usage: @spawn or {{key: value, ...}}" "\n ({} existing prototypes. Use /list to inspect)".format(ncount)) return + # A direct creation of an object from a given prototype - - prototypes = spawn(return_prototypes=True) - if not self.args: - string = "Usage: @spawn {key:value, key, value, ... }" - self.caller.msg(string + _show_prototypes(prototypes)) - return - try: - # make use of _convert_from_string from the SetAttribute command - prototype = _convert_from_string(self, self.args) - except SyntaxError: - # this means literal_eval tried to parse a faulty string - string = "|RCritical Python syntax error in argument. " - string += "Only primitive Python structures are allowed. " - string += "\nYou also need to use correct Python syntax. " - string += "Remember especially to put quotes around all " - string += "strings inside lists and dicts.|n" - self.caller.msg(string) + prototype = _parse_prototype(self.args, allow_key=True) + if not prototype: return if isinstance(prototype, basestring): - # A prototype key - keystr = prototype - prototype = prototypes.get(prototype, None) - if not prototype: - string = "No prototype named '%s'." % keystr - self.caller.msg(string + _show_prototypes(prototypes)) + # A prototype key we are looking to apply + metaprotos = search_prototype(prototype) + nprots = len(metaprotos) + if not metaprotos: + caller.msg("No prototype named '%s'." % prototype) return - elif isinstance(prototype, dict): - # we got the prototype on the command line. We must make sure to not allow - # the 'exec' key unless we are developers or higher. - if "exec" in prototype and not self.caller.check_permstring("Developer"): - self.caller.msg("Spawn aborted: You don't have access to use the 'exec' prototype key.") + elif nprots > 1: + caller.msg("Found {} prototypes matching '{}':\n {}".format( + nprots, prototype, ", ".join(metaproto.key for metaproto in metaprotos))) return - else: - self.caller.msg("The prototype must be a prototype key or a Python dictionary.") - return + # we have a metaprot, check access + metaproto = metaprotos[0] + if not caller.locks.check_lockstring(caller, metaproto.locks, access_type='use'): + caller.msg("You don't have access to use this prototype.") + return + prototype = metaproto.prototype if "noloc" not in self.switches and "location" not in prototype: prototype["location"] = self.caller.location + # proceed to spawning for obj in spawn(prototype): self.caller.msg("Spawned %s." % obj.get_display_name(self.caller)) diff --git a/evennia/commands/default/muxcommand.py b/evennia/commands/default/muxcommand.py index 5d8d4b2890..b3a0d066d5 100644 --- a/evennia/commands/default/muxcommand.py +++ b/evennia/commands/default/muxcommand.py @@ -113,7 +113,7 @@ class MuxCommand(Command): # check for arg1, arg2, ... = argA, argB, ... constructs lhs, rhs = args, None - lhslist, rhslist = [arg.strip() for arg in args.split(',')], [] + lhslist, rhslist = [arg.strip() for arg in args.split(',') if arg], [] if args and '=' in args: lhs, rhs = [arg.strip() for arg in args.split('=', 1)] lhslist = [arg.strip() for arg in lhs.split(',')] diff --git a/evennia/utils/evmore.py b/evennia/utils/evmore.py index 169091396b..e0ec091005 100644 --- a/evennia/utils/evmore.py +++ b/evennia/utils/evmore.py @@ -202,15 +202,18 @@ class EvMore(object): # goto top of the text self.page_top() - def display(self): + def display(self, show_footer=True): """ Pretty-print the page. """ pos = self._pos text = self._pages[pos] - page = _DISPLAY.format(text=text, - pageno=pos + 1, - pagemax=self._npages) + if show_footer: + page = _DISPLAY.format(text=text, + pageno=pos + 1, + pagemax=self._npages) + else: + page = text # check to make sure our session is still valid sessions = self._caller.sessions.get() if not sessions: @@ -245,9 +248,11 @@ class EvMore(object): self.page_quit() else: self._pos += 1 - self.display() - if self.exit_on_lastpage and self._pos >= self._npages - 1: - self.page_quit() + if self.exit_on_lastpage and self._pos >= (self._npages - 1): + self.display(show_footer=False) + self.page_quit(quiet=True) + else: + self.display() def page_back(self): """ @@ -256,16 +261,18 @@ class EvMore(object): self._pos = max(0, self._pos - 1) self.display() - def page_quit(self): + def page_quit(self, quiet=False): """ Quit the pager """ del self._caller.ndb._more - self._caller.msg(text=self._exit_msg, **self._kwargs) + if not quiet: + self._caller.msg(text=self._exit_msg, **self._kwargs) self._caller.cmdset.remove(CmdSetMore) -def msg(caller, text="", always_page=False, session=None, justify_kwargs=None, **kwargs): +def msg(caller, text="", always_page=False, session=None, + justify_kwargs=None, exit_on_lastpage=True, **kwargs): """ More-supported version of msg, mimicking the normal msg method. @@ -280,9 +287,10 @@ def msg(caller, text="", always_page=False, session=None, justify_kwargs=None, * justify_kwargs (dict, bool or None, optional): If given, this should be valid keyword arguments to the utils.justify() function. If False, no justification will be done. + exit_on_lastpage (bool, optional): Immediately exit pager when reaching the last page. kwargs (any, optional): These will be passed on to the `caller.msg` method. """ EvMore(caller, text, always_page=always_page, session=session, - justify_kwargs=justify_kwargs, **kwargs) + justify_kwargs=justify_kwargs, exit_on_lastpage=exit_on_lastpage, **kwargs) diff --git a/evennia/utils/olc/olc_storage.py b/evennia/utils/olc/olc_storage.py deleted file mode 100644 index cda1e6d0eb..0000000000 --- a/evennia/utils/olc/olc_storage.py +++ /dev/null @@ -1,269 +0,0 @@ -""" -OLC storage and sharing mechanism. - -This sets up a central storage for prototypes. The idea is to make these -available in a repository for buildiers to use. Each prototype is stored -in a Script so that it can be tagged for quick sorting/finding and locked for limiting -access. - -This system also takes into consideration prototypes defined and stored in modules. -Such prototypes are considered 'read-only' to the system and can only be modified -in code. To replace a default prototype, add the same-name prototype in a -custom module read later in the settings.PROTOTYPE_MODULES list. To remove a default -prototype, override its name with an empty dict. - -""" - -from collections import namedtuple -from django.conf import settings -from evennia.scripts.scripts import DefaultScript -from evennia.utils.create import create_script -from evennia.utils.utils import make_iter, all_from_module -from evennia.utils.evtable import EvTable - -# prepare the available prototypes defined in modules - -_READONLY_PROTOTYPES = {} -_READONLY_PROTOTYPE_MODULES = {} - -# storage of meta info about the prototype -MetaProto = namedtuple('MetaProto', ['key', 'desc', 'locks', 'tags', 'prototype']) - -for mod in settings.PROTOTYPE_MODULES: - # to remove a default prototype, override it with an empty dict. - # internally we store as (key, desc, locks, tags, prototype_dict) - prots = [(key, prot) for key, prot in all_from_module(mod).items() - if prot and isinstance(prot, dict)] - _READONLY_PROTOTYPES.update( - {key.lower(): MetaProto( - key.lower(), - prot['prototype_desc'] if 'prototype_desc' in prot else mod, - prot['prototype_lock'] if 'prototype_lock' in prot else "use:all()", - set(make_iter( - prot['prototype_tags']) if 'prototype_tags' in prot else ["base-prototype"]), - prot) - for key, prot in prots}) - _READONLY_PROTOTYPE_MODULES.update({tup[0]: mod for tup in prots}) - - -class PersistentPrototype(DefaultScript): - """ - This stores a single prototype - """ - def at_script_creation(self): - self.key = "empty prototype" - self.desc = "A prototype" - - -def store_prototype(caller, key, prototype, desc="", tags=None, locks="", delete=False): - """ - Store a prototype persistently. - - Args: - caller (Account or Object): Caller aiming to store prototype. At this point - the caller should have permission to 'add' new prototypes, but to edit - an existing prototype, the 'edit' lock must be passed on that prototype. - key (str): Name of prototype to store. - prototype (dict): Prototype dict. - desc (str, optional): Description of prototype, to use in listing. - tags (list, optional): Tag-strings to apply to prototype. These are always - applied with the 'persistent_prototype' category. - locks (str, optional): Locks to apply to this prototype. Used locks - are 'use' and 'edit' - delete (bool, optional): Delete an existing prototype identified by 'key'. - This requires `caller` to pass the 'edit' lock of the prototype. - Returns: - stored (StoredPrototype or None): The resulting prototype (new or edited), - or None if deleting. - Raises: - PermissionError: If edit lock was not passed by caller. - - - """ - key_orig = key - key = key.lower() - locks = locks if locks else "use:all();edit:id({}) or edit:perm(Admin)".format(caller.id) - tags = [(tag, "persistent_prototype") for tag in make_iter(tags)] - - if key in _READONLY_PROTOTYPES: - mod = _READONLY_PROTOTYPE_MODULES.get(key, "N/A") - raise PermissionError("{} is a read-only prototype " - "(defined as code in {}).".format(key_orig, mod)) - - stored_prototype = PersistentPrototype.objects.filter(db_key=key) - - if stored_prototype: - stored_prototype = stored_prototype[0] - if not stored_prototype.access(caller, 'edit'): - raise PermissionError("{} does not have permission to " - "edit prototype {}".format(caller, key)) - - if delete: - stored_prototype.delete() - return - - if desc: - stored_prototype.desc = desc - if tags: - stored_prototype.tags.batch_add(*tags) - if locks: - stored_prototype.locks.add(locks) - if prototype: - stored_prototype.attributes.add("prototype", prototype) - else: - stored_prototype = create_script( - PersistentPrototype, key=key, desc=desc, persistent=True, - locks=locks, tags=tags, attributes=[("prototype", prototype)]) - return stored_prototype - - -def search_persistent_prototype(key=None, tags=None): - """ - Find persistent (database-stored) prototypes based on key and/or tags. - - Kwargs: - key (str): An exact or partial key to query for. - tags (str or list): Tag key or keys to query for. These - will always be applied with the 'persistent_protototype' - tag category. - Return: - matches (queryset): All found PersistentPrototypes - - Note: - This will not include read-only prototypes defined in modules. - - """ - if tags: - # exact match on tag(s) - tags = make_iter(tags) - tag_categories = ["persistent_prototype" for _ in tags] - matches = PersistentPrototype.objects.get_by_tag(tags, tag_categories) - else: - matches = PersistentPrototype.objects.all() - if key: - # partial match on key - matches = matches.filter(db_key=key) or matches.filter(db_key__icontains=key) - return matches - - -def search_readonly_prototype(key=None, tags=None): - """ - Find read-only prototypes, defined in modules. - - Kwargs: - key (str): An exact or partial key to query for. - tags (str or list): Tag key to query for. - - Return: - matches (list): List of MetaProto tuples that includes - prototype metadata, - - """ - matches = {} - if tags: - # use tags to limit selection - tagset = set(tags) - matches = {key: metaproto for key, metaproto in _READONLY_PROTOTYPES.items() - if tagset.intersection(metaproto.tags)} - else: - matches = _READONLY_PROTOTYPES - - if key: - if key in matches: - # exact match - return [matches[key]] - else: - # fuzzy matching - return [metaproto for pkey, metaproto in matches.items() if key in pkey] - else: - return [match for match in matches.values()] - - -def search_prototype(key=None, tags=None): - """ - Find prototypes based on key and/or tags. - - Kwargs: - key (str): An exact or partial key to query for. - tags (str or list): Tag key or keys to query for. These - will always be applied with the 'persistent_protototype' - tag category. - Return: - matches (list): All found prototype dicts. - - Note: - The available prototypes is a combination of those supplied in - PROTOTYPE_MODULES and those stored from in-game. For the latter, - this will use the tags to make a subselection before attempting - to match on the key. So if key/tags don't match up nothing will - be found. - - """ - matches = [] - if key and key in _READONLY_PROTOTYPES: - matches.append(_READONLY_PROTOTYPES[key][3]) - else: - matches.extend([prot.attributes.get("prototype") - for prot in search_persistent_prototype(key, tags)]) - return matches - - -def get_prototype_list(caller, key=None, tags=None, show_non_use=False, show_non_edit=True): - """ - Collate a list of found prototypes based on search criteria and access. - - Args: - caller (Account or Object): The object requesting the list. - key (str, optional): Exact or partial key to query for. - tags (str or list, optional): Tag key or keys to query for. - show_non_use (bool, optional): Show also prototypes the caller may not use. - show_non_edit (bool, optional): Show also prototypes the caller may not edit. - Returns: - table (EvTable or None): An EvTable representation of the prototypes. None - if no prototypes were found. - - """ - # handle read-only prototypes separately - readonly_prototypes = search_readonly_prototype(key, tags) - - # get use-permissions of readonly attributes (edit is always False) - readonly_prototypes = [ - (metaproto.key, - metaproto.desc, - ("{}/N".format('Y' - if caller.locks.check_lockstring(caller, metaproto.locks, access_type='use') else 'N')), - ",".join(metaproto.tags)) - for metaproto in sorted(readonly_prototypes, key=lambda o: o.key)] - - # next, handle db-stored prototypes - prototypes = search_persistent_prototype(key, tags) - - # gather access permissions as (key, desc, tags, can_use, can_edit) - prototypes = [(prototype.key, prototype.desc, - "{}/{}".format('Y' if prototype.access(caller, "use") else 'N', - 'Y' if prototype.access(caller, "edit") else 'N'), - ",".join(prototype.tags.get(category="persistent_prototype"))) - for prototype in sorted(prototypes, key=lambda o: o.key)] - - prototypes = prototypes + readonly_prototypes - - if not prototypes: - return None - - if not show_non_use: - prototypes = [metaproto for metaproto in prototypes if metaproto[2].split("/", 1)[0] == 'Y'] - if not show_non_edit: - prototypes = [metaproto for metaproto in prototypes if metaproto[2].split("/", 1)[1] == 'Y'] - - if not prototypes: - return None - - table = [] - for i in range(len(prototypes[0])): - table.append([str(metaproto[i]) for metaproto in prototypes]) - table = EvTable("Key", "Desc", "Use/Edit", "Tags", table=table, crop=True, width=78) - table.reformat_column(0, width=28) - table.reformat_column(1, width=40) - table.reformat_column(2, width=11, align='r') - table.reformat_column(3, width=20) - return table diff --git a/evennia/utils/spawner.py b/evennia/utils/spawner.py index d1d4bea920..3b2bec932c 100644 --- a/evennia/utils/spawner.py +++ b/evennia/utils/spawner.py @@ -483,7 +483,7 @@ def search_readonly_prototype(key=None, tags=None): return [match for match in matches.values()] -def search_prototype(key=None, tags=None): +def search_prototype(key=None, tags=None, return_meta=True): """ Find prototypes based on key and/or tags. @@ -492,8 +492,11 @@ def search_prototype(key=None, tags=None): tags (str or list): Tag key or keys to query for. These will always be applied with the 'persistent_protototype' tag category. + return_meta (bool): If False, only return prototype dicts, if True + return MetaProto namedtuples including prototype meta info + Return: - matches (list): All found prototype dicts. + matches (list): All found prototype dicts or MetaProtos Note: The available prototypes is a combination of those supplied in @@ -505,10 +508,30 @@ def search_prototype(key=None, tags=None): """ matches = [] if key and key in _READONLY_PROTOTYPES: - matches.append(_READONLY_PROTOTYPES[key][3]) + if return_meta: + matches.append(_READONLY_PROTOTYPES[key]) + else: + matches.append(_READONLY_PROTOTYPES[key][3]) + elif tags: + if return_meta: + matches.extend( + [MetaProto(prot.key, prot.desc, prot.locks.all(), + prot.tags.all(), prot.attributes.get("prototype")) + for prot in search_persistent_prototype(key, tags)]) + else: + matches.extend([prot.attributes.get("prototype") + for prot in search_persistent_prototype(key, tags)]) else: - matches.extend([prot.attributes.get("prototype") - for prot in search_persistent_prototype(key, tags)]) + # neither key nor tags given. Return all. + if return_meta: + matches = [MetaProto(prot.key, prot.desc, prot.locks.all(), + prot.tags.all(), prot.attributes.get("prototype")) + for prot in search_persistent_prototype(key, tags)] + \ + list(_READONLY_PROTOTYPES.values()) + else: + matches = [prot.attributes.get("prototype") + for prot in search_persistent_prototype()] + \ + [metaprot[3] for metaprot in _READONLY_PROTOTYPES.values()] return matches From 0c9b2239f906d12452bd689896110538eb97c682 Mon Sep 17 00:00:00 2001 From: Griatch Date: Sun, 4 Mar 2018 16:25:18 +0100 Subject: [PATCH 045/208] Improve parse of spawn arguments --- evennia/commands/default/building.py | 126 +++++++++++++++------------ evennia/utils/spawner.py | 49 +++++------ 2 files changed, 89 insertions(+), 86 deletions(-) diff --git a/evennia/commands/default/building.py b/evennia/commands/default/building.py index dfa78ea074..d493e850b6 100644 --- a/evennia/commands/default/building.py +++ b/evennia/commands/default/building.py @@ -13,7 +13,8 @@ from evennia.utils import create, utils, search from evennia.utils.utils import inherits_from, class_from_module from evennia.utils.eveditor import EvEditor from evennia.utils.evmore import EvMore -from evennia.utils.spawner import spawn, search_prototype, list_prototypes, store_prototype +from evennia.utils.spawner import (spawn, search_prototype, list_prototypes, + store_prototype, build_metaproto) from evennia.utils.ansi import raw COMMAND_DEFAULT_CLASS = class_from_module(settings.COMMAND_DEFAULT_CLASS) @@ -27,12 +28,8 @@ __all__ = ("ObjManipCommand", "CmdSetObjAlias", "CmdCopy", "CmdLock", "CmdExamine", "CmdFind", "CmdTeleport", "CmdScript", "CmdTag", "CmdSpawn") -try: - # used by @set - from ast import literal_eval as _LITERAL_EVAL -except ImportError: - # literal_eval is not available before Python 2.6 - _LITERAL_EVAL = None +# used by @set +from ast import literal_eval as _LITERAL_EVAL # used by @find CHAR_TYPECLASS = settings.BASE_CHARACTER_TYPECLASS @@ -1450,17 +1447,16 @@ def _convert_from_string(cmd, strobj): # if nothing matches, return as-is return obj - if _LITERAL_EVAL: - # Use literal_eval to parse python structure exactly. - try: - return _LITERAL_EVAL(strobj) - except (SyntaxError, ValueError): - # treat as string - strobj = utils.to_str(strobj) - string = "|RNote: name \"|r%s|R\" was converted to a string. " \ - "Make sure this is acceptable." % strobj - cmd.caller.msg(string) - return strobj + # Use literal_eval to parse python structure exactly. + try: + return _LITERAL_EVAL(strobj) + except (SyntaxError, ValueError): + # treat as string + strobj = utils.to_str(strobj) + string = "|RNote: name \"|r%s|R\" was converted to a string. " \ + "Make sure this is acceptable." % strobj + cmd.caller.msg(string) + return strobj else: # fall back to old recursive solution (does not support # nested lists/dicts) @@ -2786,46 +2782,44 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): def func(self): """Implements the spawner""" - def _parse_prototype(inp, allow_key=False): + def _parse_prototype(inp, expect=dict): + err = None try: - # make use of _convert_from_string from the SetAttribute command - prototype = _convert_from_string(self, inp) - except SyntaxError: - # this means literal_eval tried to parse a faulty string - string = ("|RCritical Python syntax error in argument. Only primitive " - "Python structures are allowed. \nYou also need to use correct " - "Python syntax. Remember especially to put quotes around all " - "strings inside lists and dicts.|n") - self.caller.msg(string) - return None - if isinstance(prototype, dict): + prototype = _LITERAL_EVAL(inp) + except (SyntaxError, ValueError) as err: + # treat as string + prototype = utils.to_str(inp) + finally: + if not isinstance(prototype, expect): + if err: + string = ("{}\n|RCritical Python syntax error in argument. Only primitive " + "Python structures are allowed. \nYou also need to use correct " + "Python syntax. Remember especially to put quotes around all " + "strings inside lists and dicts.|n".format(err)) + else: + string = "Expected {}, got {}.".format(expect, type(prototype)) + self.caller.msg(string) + return None + if expect == dict: # an actual prototype. We need to make sure it's safe. Don't allow exec if "exec" in prototype and not self.caller.check_permstring("Developer"): self.caller.msg("Spawn aborted: You don't have access to " "use the 'exec' prototype key.") return None - elif isinstance(prototype, basestring): - # a prototype key - if allow_key: - return prototype - else: - self.caller.msg("The prototype must be defined as a Python dictionary.") - else: - caller.msg("The prototype must be given either as a Python dictionary or a key") - return None + return prototype - - def _search_show_prototype(query): + def _search_show_prototype(query, metaprots=None): # prototype detail strings = [] - metaprots = search_prototype(key=query, return_meta=True) + if not metaprots: + metaprots = search_prototype(key=query, return_meta=True) if metaprots: for metaprot in metaprots: header = ( "|cprototype key:|n {}, |ctags:|n {}, |clocks:|n {} \n" "|cdesc:|n {} \n|cprototype:|n ".format( metaprot.key, ", ".join(metaprot.tags), - metaprot.locks, metaprot.desc)) + "; ".join(metaprot.locks), metaprot.desc)) prototype = ("{{\n {} \n}}".format("\n ".join("{!r}: {!r},".format(key, value) for key, value in sorted(metaprot.prototype.items())).rstrip(","))) @@ -2869,11 +2863,12 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): if 'save' in self.switches: if not self.args or not self.rhs: - caller.msg("Usage: @spawn/save [;desc[;tag,tag[,...][;lockstring]]] = ") + caller.msg( + "Usage: @spawn/save [;desc[;tag,tag[,...][;lockstring]]] = ") return # handle lhs - parts = self.rhs.split(";", 3) + parts = self.lhs.split(";", 3) key, desc, tags, lockstring = "", "", [], "" nparts = len(parts) if nparts == 1: @@ -2889,17 +2884,26 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): tags = [tag.strip().lower() for tag in tags.split(",")] # handle rhs: - prototype = _parse_prototype(caller, self.rhs) + prototype = _parse_prototype(self.rhs) if not prototype: return - # check for existing prototype - matchstring = _search_show_prototype(key) - if matchstring: - caller.msg("|yExisting saved prototype found:|n\n{}".format(matchstring)) - answer = ("Do you want to replace the existing prototype? Y/[N]") - if not answer.lower() not in ["y", "yes"]: - caller.msg("Save cancelled.") + # present prototype to save + new_matchstring = _search_show_prototype( + "", metaprots=[build_metaproto(key, desc, [lockstring], tags, prototype)]) + string = "|yCreating new prototype:|n\n{}".format(new_matchstring) + question = "\nDo you want to continue saving? [Y]/N" + + # check for existing prototype, + old_matchstring = _search_show_prototype(key) + if old_matchstring: + string += "\n|yExisting saved prototype found:|n\n{}".format(old_matchstring) + question = "\n|yDo you want to replace the existing prototype?|n [Y]/N" + + answer = yield(string + question) + if answer.lower() in ["n", "no"]: + caller.msg("|rSave cancelled.|n") + return # all seems ok. Try to save. try: @@ -2907,8 +2911,7 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): except PermissionError as err: caller.msg("|rError saving:|R {}|n".format(err)) return - caller.msg("Saved prototype:") - caller.execute_cmd("spawn/show {}".format(key)) + caller.msg("|gSaved prototype:|n {}".format(key)) return if not self.args: @@ -2919,12 +2922,16 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): # A direct creation of an object from a given prototype - prototype = _parse_prototype(self.args, allow_key=True) + prototype = _parse_prototype( + self.args, expect=dict if self.args.strip().startswith("{") else basestring) if not prototype: + # this will only let through dicts or strings return + key = '' if isinstance(prototype, basestring): # A prototype key we are looking to apply + key = prototype metaprotos = search_prototype(prototype) nprots = len(metaprotos) if not metaprotos: @@ -2945,5 +2952,8 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): prototype["location"] = self.caller.location # proceed to spawning - for obj in spawn(prototype): - self.caller.msg("Spawned %s." % obj.get_display_name(self.caller)) + try: + for obj in spawn(prototype): + self.caller.msg("Spawned %s." % obj.get_display_name(self.caller)) + except RuntimeError as err: + caller.msg(err) diff --git a/evennia/utils/spawner.py b/evennia/utils/spawner.py index 3b2bec932c..1b0bbb63a3 100644 --- a/evennia/utils/spawner.py +++ b/evennia/utils/spawner.py @@ -359,6 +359,14 @@ class PersistentPrototype(DefaultScript): self.desc = "A prototype" +def build_metaproto(key, desc, locks, tags, prototype): + """ + Create a metaproto from combinant parts. + + """ + return MetaProto(key, desc, locks, tags, dict(prototype)) + + def store_prototype(caller, key, prototype, desc="", tags=None, locks="", delete=False): """ Store a prototype persistently. @@ -386,7 +394,7 @@ def store_prototype(caller, key, prototype, desc="", tags=None, locks="", delete """ key_orig = key key = key.lower() - locks = locks if locks else "use:all();edit:id({}) or edit:perm(Admin)".format(caller.id) + locks = locks if locks else "use:all();edit:id({}) or perm(Admin)".format(caller.id) tags = [(tag, "persistent_prototype") for tag in make_iter(tags)] if key in _READONLY_PROTOTYPES: @@ -506,34 +514,19 @@ def search_prototype(key=None, tags=None, return_meta=True): be found. """ - matches = [] - if key and key in _READONLY_PROTOTYPES: - if return_meta: - matches.append(_READONLY_PROTOTYPES[key]) - else: - matches.append(_READONLY_PROTOTYPES[key][3]) - elif tags: - if return_meta: - matches.extend( - [MetaProto(prot.key, prot.desc, prot.locks.all(), - prot.tags.all(), prot.attributes.get("prototype")) - for prot in search_persistent_prototype(key, tags)]) - else: - matches.extend([prot.attributes.get("prototype") - for prot in search_persistent_prototype(key, tags)]) - else: - # neither key nor tags given. Return all. - if return_meta: - matches = [MetaProto(prot.key, prot.desc, prot.locks.all(), - prot.tags.all(), prot.attributes.get("prototype")) - for prot in search_persistent_prototype(key, tags)] + \ - list(_READONLY_PROTOTYPES.values()) - else: - matches = [prot.attributes.get("prototype") - for prot in search_persistent_prototype()] + \ - [metaprot[3] for metaprot in _READONLY_PROTOTYPES.values()] - return matches + readonly_prototypes = search_readonly_prototype(key, tags) + persistent_prototypes = search_persistent_prototype(key, tags) + if return_meta: + persistent_prototypes = [ + build_metaproto(prot.key, prot.desc, prot.locks.all(), + prot.tags.all(), prot.attributes.get("prototype")) + for prot in persistent_prototypes] + else: + readonly_prototypes = [metaprot.prototyp for metaprot in readonly_prototypes] + persistent_prototypes = [prot.attributes.get("prototype") for prot in persistent_prototypes] + + return persistent_prototypes + readonly_prototypes def list_prototypes(caller, key=None, tags=None, show_non_use=False, show_non_edit=True): """ From ed355e6096ffc3076ec9a9a792c5cba3d2d00e08 Mon Sep 17 00:00:00 2001 From: Griatch Date: Sat, 10 Mar 2018 08:40:28 +0100 Subject: [PATCH 046/208] Test refactoring of spawner (untested) --- evennia/utils/spawner.py | 423 ++++++++++++++++++++------------------- 1 file changed, 217 insertions(+), 206 deletions(-) diff --git a/evennia/utils/spawner.py b/evennia/utils/spawner.py index 1b0bbb63a3..00cfb25627 100644 --- a/evennia/utils/spawner.py +++ b/evennia/utils/spawner.py @@ -141,212 +141,6 @@ for mod in settings.PROTOTYPE_MODULES: for key, prot in prots}) _READONLY_PROTOTYPE_MODULES.update({tup[0]: mod for tup in prots}) - -def _handle_dbref(inp): - return dbid_to_obj(inp, ObjectDB) - - -def _validate_prototype(key, prototype, protparents, visited): - """ - Run validation on a prototype, checking for inifinite regress. - - """ - assert isinstance(prototype, dict) - if id(prototype) in visited: - raise RuntimeError("%s has infinite nesting of prototypes." % key or prototype) - visited.append(id(prototype)) - protstrings = prototype.get("prototype") - if protstrings: - for protstring in make_iter(protstrings): - if key is not None and protstring == key: - raise RuntimeError("%s tries to prototype itself." % key or prototype) - protparent = protparents.get(protstring) - if not protparent: - raise RuntimeError( - "%s's prototype '%s' was not found." % (key or prototype, protstring)) - _validate_prototype(protstring, protparent, protparents, visited) - - -def _get_prototype(dic, prot, protparents): - """ - Recursively traverse a prototype dictionary, including multiple - inheritance. Use _validate_prototype before this, we don't check - for infinite recursion here. - - """ - if "prototype" in dic: - # move backwards through the inheritance - for prototype in make_iter(dic["prototype"]): - # Build the prot dictionary in reverse order, overloading - new_prot = _get_prototype(protparents.get(prototype, {}), prot, protparents) - prot.update(new_prot) - prot.update(dic) - prot.pop("prototype", None) # we don't need this anymore - return prot - - -def _batch_create_object(*objparams): - """ - This is a cut-down version of the create_object() function, - optimized for speed. It does NOT check and convert various input - so make sure the spawned Typeclass works before using this! - - Args: - objsparams (tuple): Parameters for the respective creation/add - handlers in the following order: - - `create_kwargs` (dict): For use as new_obj = `ObjectDB(**create_kwargs)`. - - `permissions` (str): Permission string used with `new_obj.batch_add(permission)`. - - `lockstring` (str): Lockstring used with `new_obj.locks.add(lockstring)`. - - `aliases` (list): A list of alias strings for - adding with `new_object.aliases.batch_add(*aliases)`. - - `nattributes` (list): list of tuples `(key, value)` to be loop-added to - add with `new_obj.nattributes.add(*tuple)`. - - `attributes` (list): list of tuples `(key, value[,category[,lockstring]])` for - adding with `new_obj.attributes.batch_add(*attributes)`. - - `tags` (list): list of tuples `(key, category)` for adding - with `new_obj.tags.batch_add(*tags)`. - - `execs` (list): Code strings to execute together with the creation - of each object. They will be executed with `evennia` and `obj` - (the newly created object) available in the namespace. Execution - will happend after all other properties have been assigned and - is intended for calling custom handlers etc. - for the respective creation/add handlers in the following - order: (create_kwargs, permissions, locks, aliases, nattributes, - attributes, tags, execs) - - Returns: - objects (list): A list of created objects - - Notes: - The `exec` list will execute arbitrary python code so don't allow this to be available to - unprivileged users! - - """ - - # bulk create all objects in one go - - # unfortunately this doesn't work since bulk_create doesn't creates pks; - # the result would be duplicate objects at the next stage, so we comment - # it out for now: - # dbobjs = _ObjectDB.objects.bulk_create(dbobjs) - - dbobjs = [ObjectDB(**objparam[0]) for objparam in objparams] - objs = [] - for iobj, obj in enumerate(dbobjs): - # call all setup hooks on each object - objparam = objparams[iobj] - # setup - obj._createdict = {"permissions": make_iter(objparam[1]), - "locks": objparam[2], - "aliases": make_iter(objparam[3]), - "nattributes": objparam[4], - "attributes": objparam[5], - "tags": make_iter(objparam[6])} - # this triggers all hooks - obj.save() - # run eventual extra code - for code in objparam[7]: - if code: - exec(code, {}, {"evennia": evennia, "obj": obj}) - objs.append(obj) - return objs - - -def spawn(*prototypes, **kwargs): - """ - Spawn a number of prototyped objects. - - Args: - prototypes (dict): Each argument should be a prototype - dictionary. - Kwargs: - prototype_modules (str or list): A python-path to a prototype - module, or a list of such paths. These will be used to build - the global protparents dictionary accessible by the input - prototypes. If not given, it will instead look for modules - defined by settings.PROTOTYPE_MODULES. - prototype_parents (dict): A dictionary holding a custom - prototype-parent dictionary. Will overload same-named - prototypes from prototype_modules. - return_prototypes (bool): Only return a list of the - prototype-parents (no object creation happens) - - """ - - protparents = {} - protmodules = make_iter(kwargs.get("prototype_modules", [])) - if not protmodules and hasattr(settings, "PROTOTYPE_MODULES"): - protmodules = make_iter(settings.PROTOTYPE_MODULES) - for prototype_module in protmodules: - protparents.update(dict((key, val) for key, val in - all_from_module(prototype_module).items() if isinstance(val, dict))) - # overload module's protparents with specifically given protparents - protparents.update(kwargs.get("prototype_parents", {})) - for key, prototype in protparents.items(): - _validate_prototype(key, prototype, protparents, []) - - if "return_prototypes" in kwargs: - # only return the parents - return copy.deepcopy(protparents) - - objsparams = [] - for prototype in prototypes: - - _validate_prototype(None, prototype, protparents, []) - prot = _get_prototype(prototype, {}, protparents) - if not prot: - continue - - # extract the keyword args we need to create the object itself. If we get a callable, - # call that to get the value (don't catch errors) - create_kwargs = {} - keyval = prot.pop("key", "Spawned Object %06i" % randint(1, 100000)) - create_kwargs["db_key"] = keyval() if callable(keyval) else keyval - - locval = prot.pop("location", None) - create_kwargs["db_location"] = locval() if callable(locval) else _handle_dbref(locval) - - homval = prot.pop("home", settings.DEFAULT_HOME) - create_kwargs["db_home"] = homval() if callable(homval) else _handle_dbref(homval) - - destval = prot.pop("destination", None) - create_kwargs["db_destination"] = destval() if callable(destval) else _handle_dbref(destval) - - typval = prot.pop("typeclass", settings.BASE_OBJECT_TYPECLASS) - create_kwargs["db_typeclass_path"] = typval() if callable(typval) else typval - - # extract calls to handlers - permval = prot.pop("permissions", []) - permission_string = permval() if callable(permval) else permval - lockval = prot.pop("locks", "") - lock_string = lockval() if callable(lockval) else lockval - aliasval = prot.pop("aliases", "") - alias_string = aliasval() if callable(aliasval) else aliasval - tagval = prot.pop("tags", []) - tags = tagval() if callable(tagval) else tagval - attrval = prot.pop("attrs", []) - attributes = attrval() if callable(tagval) else attrval - - exval = prot.pop("exec", "") - execs = make_iter(exval() if callable(exval) else exval) - - # extract ndb assignments - nattributes = dict((key.split("_", 1)[1], value() if callable(value) else value) - for key, value in prot.items() if key.startswith("ndb_")) - - # the rest are attributes - simple_attributes = [(key, value()) if callable(value) else (key, value) - for key, value in prot.items() if not key.startswith("ndb_")] - attributes = attributes + simple_attributes - attributes = [tup for tup in attributes if not tup[0] in _CREATE_OBJECT_KWARGS] - - # pack for call into _batch_create_object - objsparams.append((create_kwargs, permission_string, lock_string, - alias_string, nattributes, attributes, tags, execs)) - - return _batch_create_object(*objsparams) - - # Prototype storage mechanisms @@ -528,6 +322,20 @@ def search_prototype(key=None, tags=None, return_meta=True): return persistent_prototypes + readonly_prototypes + +def get_protparents(): + """ + Get prototype parents. These are a combination of meta-key and prototype-dict and are used when + a prototype refers to another parent-prototype. + + """ + # get all prototypes + metaprotos = search_prototype(return_meta=True) + # organize by key + return {metaproto.key: metaproto.prototype for metaproto in metaprotos} + + + def list_prototypes(caller, key=None, tags=None, show_non_use=False, show_non_edit=True): """ Collate a list of found prototypes based on search criteria and access. @@ -588,6 +396,209 @@ def list_prototypes(caller, key=None, tags=None, show_non_use=False, show_non_ed table.reformat_column(3, width=20) return table +# Spawner mechanism + + +def _handle_dbref(inp): + return dbid_to_obj(inp, ObjectDB) + + +def _validate_prototype(key, prototype, protparents, visited): + """ + Run validation on a prototype, checking for inifinite regress. + + """ + print("validate_prototype {}, {}, {}, {}".format(key, prototype, protparents, visited)) + assert isinstance(prototype, dict) + if id(prototype) in visited: + raise RuntimeError("%s has infinite nesting of prototypes." % key or prototype) + visited.append(id(prototype)) + protstrings = prototype.get("prototype") + if protstrings: + for protstring in make_iter(protstrings): + if key is not None and protstring == key: + raise RuntimeError("%s tries to prototype itself." % key or prototype) + protparent = protparents.get(protstring) + if not protparent: + raise RuntimeError( + "%s's prototype '%s' was not found." % (key or prototype, protstring)) + _validate_prototype(protstring, protparent, protparents, visited) + + +def _get_prototype(dic, prot, protparents): + """ + Recursively traverse a prototype dictionary, including multiple + inheritance. Use _validate_prototype before this, we don't check + for infinite recursion here. + + """ + if "prototype" in dic: + # move backwards through the inheritance + for prototype in make_iter(dic["prototype"]): + # Build the prot dictionary in reverse order, overloading + new_prot = _get_prototype(protparents.get(prototype.lower(), {}), prot, protparents) + prot.update(new_prot) + prot.update(dic) + prot.pop("prototype", None) # we don't need this anymore + return prot + + +def _batch_create_object(*objparams): + """ + This is a cut-down version of the create_object() function, + optimized for speed. It does NOT check and convert various input + so make sure the spawned Typeclass works before using this! + + Args: + objsparams (tuple): Parameters for the respective creation/add + handlers in the following order: + - `create_kwargs` (dict): For use as new_obj = `ObjectDB(**create_kwargs)`. + - `permissions` (str): Permission string used with `new_obj.batch_add(permission)`. + - `lockstring` (str): Lockstring used with `new_obj.locks.add(lockstring)`. + - `aliases` (list): A list of alias strings for + adding with `new_object.aliases.batch_add(*aliases)`. + - `nattributes` (list): list of tuples `(key, value)` to be loop-added to + add with `new_obj.nattributes.add(*tuple)`. + - `attributes` (list): list of tuples `(key, value[,category[,lockstring]])` for + adding with `new_obj.attributes.batch_add(*attributes)`. + - `tags` (list): list of tuples `(key, category)` for adding + with `new_obj.tags.batch_add(*tags)`. + - `execs` (list): Code strings to execute together with the creation + of each object. They will be executed with `evennia` and `obj` + (the newly created object) available in the namespace. Execution + will happend after all other properties have been assigned and + is intended for calling custom handlers etc. + for the respective creation/add handlers in the following + order: (create_kwargs, permissions, locks, aliases, nattributes, + attributes, tags, execs) + + Returns: + objects (list): A list of created objects + + Notes: + The `exec` list will execute arbitrary python code so don't allow this to be available to + unprivileged users! + + """ + + # bulk create all objects in one go + + # unfortunately this doesn't work since bulk_create doesn't creates pks; + # the result would be duplicate objects at the next stage, so we comment + # it out for now: + # dbobjs = _ObjectDB.objects.bulk_create(dbobjs) + + dbobjs = [ObjectDB(**objparam[0]) for objparam in objparams] + objs = [] + for iobj, obj in enumerate(dbobjs): + # call all setup hooks on each object + objparam = objparams[iobj] + # setup + obj._createdict = {"permissions": make_iter(objparam[1]), + "locks": objparam[2], + "aliases": make_iter(objparam[3]), + "nattributes": objparam[4], + "attributes": objparam[5], + "tags": make_iter(objparam[6])} + # this triggers all hooks + obj.save() + # run eventual extra code + for code in objparam[7]: + if code: + exec(code, {}, {"evennia": evennia, "obj": obj}) + objs.append(obj) + return objs + +def spawn(*prototypes, **kwargs): + """ + Spawn a number of prototyped objects. + + Args: + prototypes (dict): Each argument should be a prototype + dictionary. + Kwargs: + prototype_modules (str or list): A python-path to a prototype + module, or a list of such paths. These will be used to build + the global protparents dictionary accessible by the input + prototypes. If not given, it will instead look for modules + defined by settings.PROTOTYPE_MODULES. + prototype_parents (dict): A dictionary holding a custom + prototype-parent dictionary. Will overload same-named + prototypes from prototype_modules. + return_prototypes (bool): Only return a list of the + prototype-parents (no object creation happens) + + """ + # get available protparents + protparents = get_protparents() + + # overload module's protparents with specifically given protparents + protparents.update(kwargs.get("prototype_parents", {})) + for key, prototype in protparents.items(): + _validate_prototype(key.lower(), prototype, protparents, []) + + if "return_prototypes" in kwargs: + # only return the parents + return copy.deepcopy(protparents) + + objsparams = [] + for prototype in prototypes: + + _validate_prototype(None, prototype, protparents, []) + prot = _get_prototype(prototype, {}, protparents) + if not prot: + continue + + # extract the keyword args we need to create the object itself. If we get a callable, + # call that to get the value (don't catch errors) + create_kwargs = {} + keyval = prot.pop("key", "Spawned Object %06i" % randint(1, 100000)) + create_kwargs["db_key"] = keyval() if callable(keyval) else keyval + + locval = prot.pop("location", None) + create_kwargs["db_location"] = locval() if callable(locval) else _handle_dbref(locval) + + homval = prot.pop("home", settings.DEFAULT_HOME) + create_kwargs["db_home"] = homval() if callable(homval) else _handle_dbref(homval) + + destval = prot.pop("destination", None) + create_kwargs["db_destination"] = destval() if callable(destval) else _handle_dbref(destval) + + typval = prot.pop("typeclass", settings.BASE_OBJECT_TYPECLASS) + create_kwargs["db_typeclass_path"] = typval() if callable(typval) else typval + + # extract calls to handlers + permval = prot.pop("permissions", []) + permission_string = permval() if callable(permval) else permval + lockval = prot.pop("locks", "") + lock_string = lockval() if callable(lockval) else lockval + aliasval = prot.pop("aliases", "") + alias_string = aliasval() if callable(aliasval) else aliasval + tagval = prot.pop("tags", []) + tags = tagval() if callable(tagval) else tagval + attrval = prot.pop("attrs", []) + attributes = attrval() if callable(tagval) else attrval + + exval = prot.pop("exec", "") + execs = make_iter(exval() if callable(exval) else exval) + + # extract ndb assignments + nattributes = dict((key.split("_", 1)[1], value() if callable(value) else value) + for key, value in prot.items() if key.startswith("ndb_")) + + # the rest are attributes + simple_attributes = [(key, value()) if callable(value) else (key, value) + for key, value in prot.items() if not key.startswith("ndb_")] + attributes = attributes + simple_attributes + attributes = [tup for tup in attributes if not tup[0] in _CREATE_OBJECT_KWARGS] + + # pack for call into _batch_create_object + objsparams.append((create_kwargs, permission_string, lock_string, + alias_string, nattributes, attributes, tags, execs)) + + return _batch_create_object(*objsparams) + + # Testing From 610399e233bdc1e9ce9841da3a19c201f1258542 Mon Sep 17 00:00:00 2001 From: Griatch Date: Sat, 10 Mar 2018 14:43:11 +0100 Subject: [PATCH 047/208] Change validation syntax, spawn mechanism not working --- evennia/commands/default/building.py | 7 ++++- evennia/utils/spawner.py | 40 +++++++++++++++++++--------- 2 files changed, 33 insertions(+), 14 deletions(-) diff --git a/evennia/commands/default/building.py b/evennia/commands/default/building.py index d493e850b6..3efcc0d641 100644 --- a/evennia/commands/default/building.py +++ b/evennia/commands/default/building.py @@ -14,7 +14,7 @@ from evennia.utils.utils import inherits_from, class_from_module from evennia.utils.eveditor import EvEditor from evennia.utils.evmore import EvMore from evennia.utils.spawner import (spawn, search_prototype, list_prototypes, - store_prototype, build_metaproto) + store_prototype, build_metaproto, validate_prototype) from evennia.utils.ansi import raw COMMAND_DEFAULT_CLASS = class_from_module(settings.COMMAND_DEFAULT_CLASS) @@ -2806,6 +2806,11 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): self.caller.msg("Spawn aborted: You don't have access to " "use the 'exec' prototype key.") return None + try: + validate_prototype(prototype) + except RuntimeError as err: + self.caller.msg(str(err)) + return return prototype def _search_show_prototype(query, metaprots=None): diff --git a/evennia/utils/spawner.py b/evennia/utils/spawner.py index 00cfb25627..412df31126 100644 --- a/evennia/utils/spawner.py +++ b/evennia/utils/spawner.py @@ -370,7 +370,7 @@ def list_prototypes(caller, key=None, tags=None, show_non_use=False, show_non_ed prototypes = [(prototype.key, prototype.desc, "{}/{}".format('Y' if prototype.access(caller, "use") else 'N', 'Y' if prototype.access(caller, "edit") else 'N'), - ",".join(prototype.tags.get(category="persistent_prototype"))) + ",".join(prototype.tags.get(category="persistent_prototype", return_list=True))) for prototype in sorted(prototypes, key=lambda o: o.key)] prototypes = prototypes + readonly_prototypes @@ -403,32 +403,45 @@ def _handle_dbref(inp): return dbid_to_obj(inp, ObjectDB) -def _validate_prototype(key, prototype, protparents, visited): +def validate_prototype(prototype, protkey=None, protparents=None, _visited=None): """ Run validation on a prototype, checking for inifinite regress. + Args: + prototype (dict): Prototype to validate. + protkey (str, optional): The name of the prototype definition, if any. + protpartents (dict, optional): The available prototype parent library. If + note given this will be determined from settings/database. + _visited (list, optional): This is an internal work array and should not be set manually. + Raises: + RuntimeError: If prototype has invalid structure. + """ - print("validate_prototype {}, {}, {}, {}".format(key, prototype, protparents, visited)) + print("validate_prototype {}, {}, {}, {}".format(protkey, prototype, protparents, _visited)) + if not protparents: + protparents = get_protparents() + if _visited is None: + _visited = [] assert isinstance(prototype, dict) - if id(prototype) in visited: - raise RuntimeError("%s has infinite nesting of prototypes." % key or prototype) - visited.append(id(prototype)) + if id(prototype) in _visited: + raise RuntimeError("%s has infinite nesting of prototypes." % protkey or prototype) + _visited.append(id(prototype)) protstrings = prototype.get("prototype") if protstrings: for protstring in make_iter(protstrings): - if key is not None and protstring == key: - raise RuntimeError("%s tries to prototype itself." % key or prototype) + if protkey is not None and protstring == protkey: + raise RuntimeError("%s tries to prototype itself." % protkey or prototype) protparent = protparents.get(protstring) if not protparent: raise RuntimeError( - "%s's prototype '%s' was not found." % (key or prototype, protstring)) - _validate_prototype(protstring, protparent, protparents, visited) + "%s's prototype '%s' was not found." % (protkey or prototype, protstring)) + validate_prototype(protparent, protstring, protparents, _visited) def _get_prototype(dic, prot, protparents): """ Recursively traverse a prototype dictionary, including multiple - inheritance. Use _validate_prototype before this, we don't check + inheritance. Use validate_prototype before this, we don't check for infinite recursion here. """ @@ -509,6 +522,7 @@ def _batch_create_object(*objparams): objs.append(obj) return objs + def spawn(*prototypes, **kwargs): """ Spawn a number of prototyped objects. @@ -535,7 +549,7 @@ def spawn(*prototypes, **kwargs): # overload module's protparents with specifically given protparents protparents.update(kwargs.get("prototype_parents", {})) for key, prototype in protparents.items(): - _validate_prototype(key.lower(), prototype, protparents, []) + validate_prototype(prototype, key.lower(), protparents) if "return_prototypes" in kwargs: # only return the parents @@ -544,7 +558,7 @@ def spawn(*prototypes, **kwargs): objsparams = [] for prototype in prototypes: - _validate_prototype(None, prototype, protparents, []) + validate_prototype(prototype, None, protparents) prot = _get_prototype(prototype, {}, protparents) if not prot: continue From 6f5b04e85ebd1d47d68d0a4a6bfb4c80ac09d373 Mon Sep 17 00:00:00 2001 From: Griatch Date: Sat, 10 Mar 2018 14:57:08 +0100 Subject: [PATCH 048/208] Working spawning from both module and store --- evennia/utils/spawner.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/evennia/utils/spawner.py b/evennia/utils/spawner.py index 412df31126..3e4bf78112 100644 --- a/evennia/utils/spawner.py +++ b/evennia/utils/spawner.py @@ -422,13 +422,19 @@ def validate_prototype(prototype, protkey=None, protparents=None, _visited=None) protparents = get_protparents() if _visited is None: _visited = [] + protkey = protkey.lower() if protkey is not None else None + assert isinstance(prototype, dict) + if id(prototype) in _visited: raise RuntimeError("%s has infinite nesting of prototypes." % protkey or prototype) + _visited.append(id(prototype)) protstrings = prototype.get("prototype") + if protstrings: for protstring in make_iter(protstrings): + protstring = protstring.lower() if protkey is not None and protstring == protkey: raise RuntimeError("%s tries to prototype itself." % protkey or prototype) protparent = protparents.get(protstring) @@ -546,6 +552,8 @@ def spawn(*prototypes, **kwargs): # get available protparents protparents = get_protparents() + print("protparents: {}".format(protparents)) + # overload module's protparents with specifically given protparents protparents.update(kwargs.get("prototype_parents", {})) for key, prototype in protparents.items(): From 2258530fde2a5f1903c917ef2ef53d91a7adbd09 Mon Sep 17 00:00:00 2001 From: Griatch Date: Sun, 11 Mar 2018 00:38:32 +0100 Subject: [PATCH 049/208] Start making tree-parser of prototypes --- evennia/commands/default/building.py | 2 +- evennia/utils/spawner.py | 72 ++++++++++++++++++++-------- 2 files changed, 53 insertions(+), 21 deletions(-) diff --git a/evennia/commands/default/building.py b/evennia/commands/default/building.py index 3efcc0d641..9d8f0277c2 100644 --- a/evennia/commands/default/building.py +++ b/evennia/commands/default/building.py @@ -2803,7 +2803,7 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): if expect == dict: # an actual prototype. We need to make sure it's safe. Don't allow exec if "exec" in prototype and not self.caller.check_permstring("Developer"): - self.caller.msg("Spawn aborted: You don't have access to " + self.caller.msg("Spawn aborted: You are not allowed to " "use the 'exec' prototype key.") return None try: diff --git a/evennia/utils/spawner.py b/evennia/utils/spawner.py index 3e4bf78112..9a0c95641b 100644 --- a/evennia/utils/spawner.py +++ b/evennia/utils/spawner.py @@ -223,7 +223,7 @@ def store_prototype(caller, key, prototype, desc="", tags=None, locks="", delete return stored_prototype -def search_persistent_prototype(key=None, tags=None): +def search_persistent_prototype(key=None, tags=None, return_metaprotos=False): """ Find persistent (database-stored) prototypes based on key and/or tags. @@ -232,8 +232,10 @@ def search_persistent_prototype(key=None, tags=None): tags (str or list): Tag key or keys to query for. These will always be applied with the 'persistent_protototype' tag category. + return_metaproto (bool): Return results as metaprotos. Return: - matches (queryset): All found PersistentPrototypes + matches (queryset or list): All found PersistentPrototypes. If `return_metaprotos` + is set, return a list of MetaProtos. Note: This will not include read-only prototypes defined in modules. @@ -249,6 +251,11 @@ def search_persistent_prototype(key=None, tags=None): if key: # partial match on key matches = matches.filter(db_key=key) or matches.filter(db_key__icontains=key) + if return_metaprotos: + return [build_metaproto(match.key, match.desc, match.locks.all(), + match.tags.get(category="persistent_prototype", return_list=True), + match.attributes.get("prototype")) + for match in matches] return matches @@ -335,8 +342,30 @@ def get_protparents(): return {metaproto.key: metaproto.prototype for metaproto in metaprotos} +def gather_prototype_tree(metaprotos): + """ + Build nested structure of metaprotos, starting from the roots with no parents. -def list_prototypes(caller, key=None, tags=None, show_non_use=False, show_non_edit=True): + Args: + metaprotos (list): All metaprotos to structure. + Returns: + tree (list): A list of lists representing all root metaprotos and + their children. + """ + roots = [mproto for mproto in metaprotos if 'prototype' not in mproto] + + def _iterate_tree(root): + rootkey = root.key + children = [_iterate_tree(mproto) for mproto in metaprotos + if mproto.prototype.get('prototype') == rootkey] + if children: + return children + return root + return [_iterate_tree(root) for root in roots] + + +def list_prototypes(caller, key=None, tags=None, show_non_use=False, + show_non_edit=True, sort_tree=True): """ Collate a list of found prototypes based on search criteria and access. @@ -346,34 +375,38 @@ def list_prototypes(caller, key=None, tags=None, show_non_use=False, show_non_ed tags (str or list, optional): Tag key or keys to query for. show_non_use (bool, optional): Show also prototypes the caller may not use. show_non_edit (bool, optional): Show also prototypes the caller may not edit. + sort_tree (bool, optional): Order prototypes by inheritance tree. Returns: table (EvTable or None): An EvTable representation of the prototypes. None if no prototypes were found. """ - # handle read-only prototypes separately - readonly_prototypes = search_readonly_prototype(key, tags) + # get metaprotos for readonly and db-based prototypes + metaprotos = search_readonly_prototype(key, tags) + metaprotos += search_persistent_prototype(key, tags, return_metaprotos=True) + + if sort_tree: + def _print_tree(mproto, level=0): + + prototypes = [ + (metaproto.key, + metaproto.desc, + ("{}/N".format('Y' + if caller.locks.check_lockstring(caller, metaproto.locks, access_type='use') else 'N')), + ",".join(metaproto.tags)) + for metaproto in sorted(metaprotos, key=lambda o: o.key)] + + tree = gather_prototype_tree(metaprotos) + # get use-permissions of readonly attributes (edit is always False) - readonly_prototypes = [ + prototypes = [ (metaproto.key, metaproto.desc, ("{}/N".format('Y' if caller.locks.check_lockstring(caller, metaproto.locks, access_type='use') else 'N')), ",".join(metaproto.tags)) - for metaproto in sorted(readonly_prototypes, key=lambda o: o.key)] - - # next, handle db-stored prototypes - prototypes = search_persistent_prototype(key, tags) - - # gather access permissions as (key, desc, tags, can_use, can_edit) - prototypes = [(prototype.key, prototype.desc, - "{}/{}".format('Y' if prototype.access(caller, "use") else 'N', - 'Y' if prototype.access(caller, "edit") else 'N'), - ",".join(prototype.tags.get(category="persistent_prototype", return_list=True))) - for prototype in sorted(prototypes, key=lambda o: o.key)] - - prototypes = prototypes + readonly_prototypes + for metaproto in sorted(metaprotos, key=lambda o: o.key)] if not prototypes: return None @@ -417,7 +450,6 @@ def validate_prototype(prototype, protkey=None, protparents=None, _visited=None) RuntimeError: If prototype has invalid structure. """ - print("validate_prototype {}, {}, {}, {}".format(protkey, prototype, protparents, _visited)) if not protparents: protparents = get_protparents() if _visited is None: From 40d9bd4ff50c7e12ab67218b53c6b3d234f73fc5 Mon Sep 17 00:00:00 2001 From: Griatch Date: Thu, 15 Mar 2018 21:19:06 +0100 Subject: [PATCH 050/208] Start refining tree display --- evennia/utils/spawner.py | 59 ++++++++++++++++++++++++++++------------ 1 file changed, 41 insertions(+), 18 deletions(-) diff --git a/evennia/utils/spawner.py b/evennia/utils/spawner.py index 9a0c95641b..5a154ef4c1 100644 --- a/evennia/utils/spawner.py +++ b/evennia/utils/spawner.py @@ -111,7 +111,7 @@ import evennia from evennia.objects.models import ObjectDB from evennia.utils.utils import make_iter, all_from_module, dbid_to_obj -from collections import namedtuple +from collections import namedtuple, defaultdict from evennia.scripts.scripts import DefaultScript from evennia.utils.create import create_script from evennia.utils.evtable import EvTable @@ -342,7 +342,7 @@ def get_protparents(): return {metaproto.key: metaproto.prototype for metaproto in metaprotos} -def gather_prototype_tree(metaprotos): +def get_prototype_tree(metaprotos): """ Build nested structure of metaprotos, starting from the roots with no parents. @@ -352,16 +352,40 @@ def gather_prototype_tree(metaprotos): tree (list): A list of lists representing all root metaprotos and their children. """ - roots = [mproto for mproto in metaprotos if 'prototype' not in mproto] + mapping = {mproto.key.lower(): mproto for mproto in metaprotos} + parents = defaultdict(list) + + for key, mproto in mapping: + proto = mproto.prototype.get('prototype', None) + if isinstance(proto, basestring): + parents[key].append(proto.lower()) + elif isinstance(proto, (tuple, list)): + parents[key].extend([pro.lower() for pro in proto]) + + def _iterate(root): + prts = parents[root] + + + + return parents + + roots = [root for root in metaprotos if not root.prototype.get('prototype')] def _iterate_tree(root): - rootkey = root.key - children = [_iterate_tree(mproto) for mproto in metaprotos - if mproto.prototype.get('prototype') == rootkey] + rootkey = root.key.lower() + children = [ + _iterate_tree(mproto) for mproto in metaprotos + if rootkey in [mp.lower() for mp in make_iter(mproto.prototype.get('prototype', ''))]] if children: return children return root - return [_iterate_tree(root) for root in roots] + tree = [] + for root in roots: + tree.append(root) + branch = _iterate_tree(root) + if branch: + tree.append(branch) + return tree def list_prototypes(caller, key=None, tags=None, show_non_use=False, @@ -386,18 +410,17 @@ def list_prototypes(caller, key=None, tags=None, show_non_use=False, metaprotos += search_persistent_prototype(key, tags, return_metaprotos=True) if sort_tree: - def _print_tree(mproto, level=0): - - prototypes = [ - (metaproto.key, - metaproto.desc, - ("{}/N".format('Y' - if caller.locks.check_lockstring(caller, metaproto.locks, access_type='use') else 'N')), - ",".join(metaproto.tags)) - for metaproto in sorted(metaprotos, key=lambda o: o.key)] - - tree = gather_prototype_tree(metaprotos) + def _print_tree(struct, level=0): + indent = " " * level + if isinstance(struct, list): + # a sub-branch + return "\n".join("{}{}".format( + indent, _print_tree(leaf, level + 2)) for leaf in struct) + else: + # an actual mproto + return "{}{}".format(indent, struct.key) + print(_print_tree(get_prototype_tree(metaprotos))) # get use-permissions of readonly attributes (edit is always False) prototypes = [ From 5d313b0cac51929955cfb9ac6d56caf3a67cf0ae Mon Sep 17 00:00:00 2001 From: Griatch Date: Thu, 15 Mar 2018 22:41:18 +0100 Subject: [PATCH 051/208] Test with different tree solution --- evennia/utils/spawner.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/evennia/utils/spawner.py b/evennia/utils/spawner.py index 5a154ef4c1..6b12f99ac6 100644 --- a/evennia/utils/spawner.py +++ b/evennia/utils/spawner.py @@ -362,12 +362,14 @@ def get_prototype_tree(metaprotos): elif isinstance(proto, (tuple, list)): parents[key].extend([pro.lower() for pro in proto]) - def _iterate(root): - prts = parents[root] + def _iterate(child, level=0): + tree = [_iterate(parent, level + 1) for parent in parents[key]] + return tree if tree else level * " " + child + for key in parents: + print("Mproto {}:\n{}".format(_iterate(key, level=0))) - - return parents + return [] roots = [root for root in metaprotos if not root.prototype.get('prototype')] From ceee65eb0f7ccdf993575f85829ebb1043e7b2d7 Mon Sep 17 00:00:00 2001 From: Griatch Date: Sat, 17 Mar 2018 18:17:28 +0100 Subject: [PATCH 052/208] Bug fixes for spawner olc --- evennia/commands/default/building.py | 7 ++- evennia/utils/spawner.py | 87 +++++----------------------- 2 files changed, 18 insertions(+), 76 deletions(-) diff --git a/evennia/commands/default/building.py b/evennia/commands/default/building.py index 9d8f0277c2..3730cc934e 100644 --- a/evennia/commands/default/building.py +++ b/evennia/commands/default/building.py @@ -2743,8 +2743,9 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): location will default to caller's current location. search - search prototype by name or tags. list - list available prototypes, optionally limit by tags. - show - inspect prototype by key. If not given, acts like list. + show, examine - inspect prototype by key. If not given, acts like list. save - save a prototype to the database. It will be listable by /list. + delete - remove a prototype from database, if allowed to. menu - manipulate prototype in a menu interface. Example: @@ -2824,7 +2825,7 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): "|cprototype key:|n {}, |ctags:|n {}, |clocks:|n {} \n" "|cdesc:|n {} \n|cprototype:|n ".format( metaprot.key, ", ".join(metaprot.tags), - "; ".join(metaprot.locks), metaprot.desc)) + metaprot.locks, metaprot.desc)) prototype = ("{{\n {} \n}}".format("\n ".join("{!r}: {!r},".format(key, value) for key, value in sorted(metaprot.prototype.items())).rstrip(","))) @@ -2848,7 +2849,7 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): exit_on_lastpage=True) return - if 'show' in self.switches: + if 'show' in self.switches or 'examine' in self.switches: # the argument is a key in this case (may be a partial key) if not self.args: self.switches.append('list') diff --git a/evennia/utils/spawner.py b/evennia/utils/spawner.py index 6b12f99ac6..92f1261f7e 100644 --- a/evennia/utils/spawner.py +++ b/evennia/utils/spawner.py @@ -158,7 +158,7 @@ def build_metaproto(key, desc, locks, tags, prototype): Create a metaproto from combinant parts. """ - return MetaProto(key, desc, locks, tags, dict(prototype)) + return MetaProto(key, desc, make_iter(locks), tags, dict(prototype)) def store_prototype(caller, key, prototype, desc="", tags=None, locks="", delete=False): @@ -249,7 +249,7 @@ def search_persistent_prototype(key=None, tags=None, return_metaprotos=False): else: matches = PersistentPrototype.objects.all() if key: - # partial match on key + # exact or partial match on key matches = matches.filter(db_key=key) or matches.filter(db_key__icontains=key) if return_metaprotos: return [build_metaproto(match.key, match.desc, match.locks.all(), @@ -316,18 +316,20 @@ def search_prototype(key=None, tags=None, return_meta=True): """ readonly_prototypes = search_readonly_prototype(key, tags) - persistent_prototypes = search_persistent_prototype(key, tags) + persistent_prototypes = search_persistent_prototype(key, tags, return_metaprotos=True) - if return_meta: - persistent_prototypes = [ - build_metaproto(prot.key, prot.desc, prot.locks.all(), - prot.tags.all(), prot.attributes.get("prototype")) - for prot in persistent_prototypes] - else: - readonly_prototypes = [metaprot.prototyp for metaprot in readonly_prototypes] - persistent_prototypes = [prot.attributes.get("prototype") for prot in persistent_prototypes] + matches = persistent_prototypes + readonly_prototypes + if len(matches) > 1 and key: + key = key.lower() + # avoid duplicates if an exact match exist between the two types + filter_matches = [mta for mta in matches if mta.key == key] + if len(filter_matches) < len(matches): + matches = filter_matches - return persistent_prototypes + readonly_prototypes + if not return_meta: + matches = [mta.prototype for mta in matches] + + return matches def get_protparents(): @@ -342,54 +344,6 @@ def get_protparents(): return {metaproto.key: metaproto.prototype for metaproto in metaprotos} -def get_prototype_tree(metaprotos): - """ - Build nested structure of metaprotos, starting from the roots with no parents. - - Args: - metaprotos (list): All metaprotos to structure. - Returns: - tree (list): A list of lists representing all root metaprotos and - their children. - """ - mapping = {mproto.key.lower(): mproto for mproto in metaprotos} - parents = defaultdict(list) - - for key, mproto in mapping: - proto = mproto.prototype.get('prototype', None) - if isinstance(proto, basestring): - parents[key].append(proto.lower()) - elif isinstance(proto, (tuple, list)): - parents[key].extend([pro.lower() for pro in proto]) - - def _iterate(child, level=0): - tree = [_iterate(parent, level + 1) for parent in parents[key]] - return tree if tree else level * " " + child - - for key in parents: - print("Mproto {}:\n{}".format(_iterate(key, level=0))) - - return [] - - roots = [root for root in metaprotos if not root.prototype.get('prototype')] - - def _iterate_tree(root): - rootkey = root.key.lower() - children = [ - _iterate_tree(mproto) for mproto in metaprotos - if rootkey in [mp.lower() for mp in make_iter(mproto.prototype.get('prototype', ''))]] - if children: - return children - return root - tree = [] - for root in roots: - tree.append(root) - branch = _iterate_tree(root) - if branch: - tree.append(branch) - return tree - - def list_prototypes(caller, key=None, tags=None, show_non_use=False, show_non_edit=True, sort_tree=True): """ @@ -411,19 +365,6 @@ def list_prototypes(caller, key=None, tags=None, show_non_use=False, metaprotos = search_readonly_prototype(key, tags) metaprotos += search_persistent_prototype(key, tags, return_metaprotos=True) - if sort_tree: - def _print_tree(struct, level=0): - indent = " " * level - if isinstance(struct, list): - # a sub-branch - return "\n".join("{}{}".format( - indent, _print_tree(leaf, level + 2)) for leaf in struct) - else: - # an actual mproto - return "{}{}".format(indent, struct.key) - - print(_print_tree(get_prototype_tree(metaprotos))) - # get use-permissions of readonly attributes (edit is always False) prototypes = [ (metaproto.key, From a4966c7edacd20b15cb93233ffde57fed833000c Mon Sep 17 00:00:00 2001 From: Griatch Date: Sat, 17 Mar 2018 19:47:44 +0100 Subject: [PATCH 053/208] Spawner/olc mechanism working --- evennia/commands/default/building.py | 28 +++++++++++++++++++++---- evennia/utils/spawner.py | 31 ++++++++++++++++++++++++---- 2 files changed, 51 insertions(+), 8 deletions(-) diff --git a/evennia/commands/default/building.py b/evennia/commands/default/building.py index 3730cc934e..9ea9707498 100644 --- a/evennia/commands/default/building.py +++ b/evennia/commands/default/building.py @@ -14,7 +14,8 @@ from evennia.utils.utils import inherits_from, class_from_module from evennia.utils.eveditor import EvEditor from evennia.utils.evmore import EvMore from evennia.utils.spawner import (spawn, search_prototype, list_prototypes, - store_prototype, build_metaproto, validate_prototype) + store_prototype, build_metaproto, validate_prototype, + delete_prototype, PermissionError) from evennia.utils.ansi import raw COMMAND_DEFAULT_CLASS = class_from_module(settings.COMMAND_DEFAULT_CLASS) @@ -2777,9 +2778,6 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): locks = "cmd:perm(spawn) or perm(Builder)" help_category = "Building" - def parser(self): - super(CmdSpawn, self).parser() - def func(self): """Implements the spawner""" @@ -2867,7 +2865,28 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): tags=self.lhslist)), exit_on_lastpage=True) return + if 'delete' in self.switches: + # remove db-based prototype + matchstring = _search_show_prototype(self.args) + if matchstring: + question = "\nDo you want to continue deleting? [Y]/N" + string = "|rDeleting prototype:|n\n{}".format(matchstring) + answer = yield(string + question) + if answer.lower() in ["n", "no"]: + caller.msg("|rDeletion cancelled.|n") + return + try: + success = delete_prototype(caller, self.args) + except PermissionError as err: + caller.msg("|rError deleting:|R {}|n".format(err)) + caller.msg("Deletion {}.".format( + 'successful' if success else 'failed (does the prototype exist?)')) + return + else: + caller.msg("Could not find prototype '{}'".format(key)) + if 'save' in self.switches: + # store a prototype to the database store if not self.args or not self.rhs: caller.msg( "Usage: @spawn/save [;desc[;tag,tag[,...][;lockstring]]] = ") @@ -2902,6 +2921,7 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): # check for existing prototype, old_matchstring = _search_show_prototype(key) + if old_matchstring: string += "\n|yExisting saved prototype found:|n\n{}".format(old_matchstring) question = "\n|yDo you want to replace the existing prototype?|n [Y]/N" diff --git a/evennia/utils/spawner.py b/evennia/utils/spawner.py index 92f1261f7e..2a003069a4 100644 --- a/evennia/utils/spawner.py +++ b/evennia/utils/spawner.py @@ -122,6 +122,9 @@ _READONLY_PROTOTYPES = {} _READONLY_PROTOTYPE_MODULES = {} +class PermissionError(RuntimeError): + pass + # storage of meta info about the prototype MetaProto = namedtuple('MetaProto', ['key', 'desc', 'locks', 'tags', 'prototype']) @@ -199,14 +202,16 @@ def store_prototype(caller, key, prototype, desc="", tags=None, locks="", delete stored_prototype = PersistentPrototype.objects.filter(db_key=key) if stored_prototype: + # edit existing prototype stored_prototype = stored_prototype[0] if not stored_prototype.access(caller, 'edit'): raise PermissionError("{} does not have permission to " - "edit prototype {}".format(caller, key)) + "edit prototype {}.".format(caller, key)) if delete: + # delete prototype stored_prototype.delete() - return + return True if desc: stored_prototype.desc = desc @@ -216,13 +221,33 @@ def store_prototype(caller, key, prototype, desc="", tags=None, locks="", delete stored_prototype.locks.add(locks) if prototype: stored_prototype.attributes.add("prototype", prototype) + elif delete: + # didn't find what to delete + return False else: + # create a new prototype stored_prototype = create_script( PersistentPrototype, key=key, desc=desc, persistent=True, locks=locks, tags=tags, attributes=[("prototype", prototype)]) return stored_prototype +def delete_prototype(caller, key): + """ + Delete a stored prototype + + Args: + caller (Account or Object): Caller aiming to delete a prototype. + key (str): The persistent prototype to delete. + Returns: + success (bool): If deletion worked or not. + Raises: + PermissionError: If 'edit' lock was not passed. + + """ + return store_prototype(caller, key, None, delete=True) + + def search_persistent_prototype(key=None, tags=None, return_metaprotos=False): """ Find persistent (database-stored) prototypes based on key and/or tags. @@ -550,8 +575,6 @@ def spawn(*prototypes, **kwargs): # get available protparents protparents = get_protparents() - print("protparents: {}".format(protparents)) - # overload module's protparents with specifically given protparents protparents.update(kwargs.get("prototype_parents", {})) for key, prototype in protparents.items(): From 8b0eb66ba6d5416e50234f3f78aee904943086aa Mon Sep 17 00:00:00 2001 From: friarzen Date: Sat, 17 Mar 2018 20:50:51 +0000 Subject: [PATCH 054/208] Alpha webclient split interface support --- .../static/webclient/css/webclient.css | 167 +++++++++------ .../static/webclient/js/splithandler.js | 77 +++++++ .../static/webclient/js/webclient_gui.js | 198 +++++++++++++----- .../webclient/templates/webclient/base.html | 113 +++++----- .../templates/webclient/webclient.html | 57 ++++- 5 files changed, 434 insertions(+), 178 deletions(-) create mode 100644 evennia/web/webclient/static/webclient/js/splithandler.js diff --git a/evennia/web/webclient/static/webclient/css/webclient.css b/evennia/web/webclient/static/webclient/css/webclient.css index 1c94a1f9fd..94344386a1 100644 --- a/evennia/web/webclient/static/webclient/css/webclient.css +++ b/evennia/web/webclient/static/webclient/css/webclient.css @@ -8,10 +8,11 @@ --- */ /* Overall element look */ -html, body, #clientwrapper { height: 100% } +html, body { + height: 100%; + width: 100%; +} body { - margin: 0; - padding: 0; background: #000; color: #ccc; font-size: .9em; @@ -19,6 +20,12 @@ body { line-height: 1.6em; overflow: hidden; } +@media screen and (max-width: 480px) { + body { + font-size: .5rem; + line-height: .7rem; + } +} a:link, a:visited { color: inherit; } @@ -74,93 +81,75 @@ div {margin:0px;} } /* Style specific classes corresponding to formatted, narative text. */ - +.wrapper { + height: 100%; +} /* Container surrounding entire client */ -#wrapper { - position: relative; - height: 100% +#clientwrapper { + height: 100%; } /* Main scrolling message area */ + #messagewindow { - position: absolute; - overflow: auto; - padding: 1em; - -webkit-box-sizing: border-box; - -moz-box-sizing: border-box; - box-sizing: border-box; - top: 0; - left: 0; - right: 0; - bottom: 70px; + overflow-y: auto; + overflow-x: hidden; + overflow-wrap: break-word; } -/* Input area containing input field and button */ -#inputform { - position: absolute; - width: 100%; - padding: 0; - bottom: 0; - margin: 0; - padding-bottom: 10px; - border-top: 1px solid #555; -} - -#inputcontrol { - width: 100%; - padding: 0; +#messagewindow { + overflow-y: auto; + overflow-x: hidden; + overflow-wrap: break-word; } /* Input field */ -#inputfield, #inputsend, #inputsizer { - display: block; - -webkit-box-sizing: border-box; - -moz-box-sizing: border-box; - box-sizing: border-box; - height: 50px; +#inputfield, #inputsizer { + height: 100%; background: #000; color: #fff; - padding: 0 .45em; - font-size: 1.1em; + padding: 0 .45rem; + font-size: 1.1rem; font-family: 'DejaVu Sans Mono', Consolas, Inconsolata, 'Lucida Console', monospace; -} - -#inputfield, #inputsizer { - float: left; - width: 95%; - border: 0; resize: none; - line-height: normal; +} +#inputsend { + height: 100%; +} +#inputcontrol { + height: 100%; } #inputfield:focus { - outline: 0; -} - -#inputsizer { - margin-left: -9999px; -} - -/* Input 'send' button */ -#inputsend { - float: right; - width: 3%; - max-width: 25px; - margin-right: 10px; - border: 0; - background: #555; } /* prompt area above input field */ -#prompt { - margin-top: 10px; - padding: 0 .45em; +.prompt { + max-height: 3rem; +} + +.splitbutton { + position: absolute; + right: 1%; + top: 1%; + z-index: 1; + width: 2rem; + height: 2rem; + font-size: 2rem; + color: #a6a6a6; + background-color: transparent; + border: 0px; +} + +.splitbutton:hover { + color: white; + cursor: pointer; } #optionsbutton { - width: 40px; - font-size: 20px; + width: 2rem; + font-size: 2rem; color: #a6a6a6; background-color: transparent; border: 0px; @@ -173,8 +162,8 @@ div {margin:0px;} #toolbar { position: fixed; - top: 0; - right: 5px; + top: .5rem; + right: .5rem; z-index: 1; } @@ -248,6 +237,48 @@ div {margin:0px;} text-decoration: none; cursor: pointer; } +.gutter.gutter-vertical { + cursor: row-resize; + background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAB4AAAAFAQMAAABo7865AAAABlBMVEVHcEzMzMzyAv2sAAAAAXRSTlMAQObYZgAAABBJREFUeF5jOAMEEAIEEFwAn3kMwcB6I2AAAAAASUVORK5CYII=') +} + +.gutter.gutter-horizontal { + cursor: col-resize; + background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAeCAYAAADkftS9AAAAIklEQVQoU2M4c+bMfxAGAgYYmwGrIIiDjrELjpo5aiZeMwF+yNnOs5KSvgAAAABJRU5ErkJggg==') +} + +.split { + -webkit-box-sizing: border-box; + -moz-box-sizing: border-box; + box-sizing: border-box; + + overflow-y: auto; + overflow-x: hidden; +} + +.content { + border: 1px solid #C0C0C0; + box-shadow: inset 0 1px 2px #e4e4e4; + background-color: black; + padding: 1rem; +} +@media screen and (max-width: 480px) { + .content { + padding: .5rem; + } +} + +.gutter { + background-color: grey; + + background-repeat: no-repeat; + background-position: 50%; +} + +.split.split-horizontal, .gutter.gutter-horizontal { + height: 100%; + float: left; +} /* XTERM256 colors */ diff --git a/evennia/web/webclient/static/webclient/js/splithandler.js b/evennia/web/webclient/static/webclient/js/splithandler.js new file mode 100644 index 0000000000..56890009e5 --- /dev/null +++ b/evennia/web/webclient/static/webclient/js/splithandler.js @@ -0,0 +1,77 @@ +// Use split.js to create a basic ui +var SplitHandler = (function () { + var num_splits = 0; + var split_panes = {}; + + var set_pane_types = function(splitpane, types) { + split_panes[splitpane]['types'] = types; + } + + var dynamic_split = function(splitpane, direction, update_method1, update_method2) { + var first = ++num_splits; + var second = ++num_splits; + + var first_div = $( '
' ) + var first_sub = $( '
' ) + var second_div = $( '
' ) + var second_sub = $( '
' ) + + // check to see if this pane contains the primary message window. + contents = $('#'+splitpane).contents(); + if( contents ) { + // it does, so move it to the first new div (TODO -- selectable between first/second?) + contents.appendTo(first_sub); + } + + first_div.append( first_sub ); + second_div.append( second_sub ); + + // update the split_panes array to remove this split + delete( split_panes[splitpane] ); + + // now vaporize the current split_N-sub placeholder and create two new panes. + $('#'+splitpane).parent().append(first_div); + $('#'+splitpane).parent().append(second_div); + $('#'+splitpane).remove(); + + // And split + Split(['#split_'+first,'#split_'+second], { + direction: direction, + sizes: [50,50], + gutterSize: 4, + minSize: [50,50], + }); + + // store our new splits for future splits/uses by the main UI. + split_panes['split_'+first +'-sub'] = { 'types': [], 'update_method': update_method1 }; + split_panes['split_'+second+'-sub'] = { 'types': [], 'update_method': update_method2 }; + } + + + var init = function(settings) { + //change Mustache tags to ruby-style (Django gets mad otherwise) + var customTags = [ '<%', '%>' ]; + Mustache.tags = customTags; + + var input_template = $('#input-template').html(); + Mustache.parse(input_template); + + Split(['#main','#input'], { + direction: 'vertical', + sizes: [90,10], + gutterSize: 4, + minSize: [50,50], + }); + + var input_render = Mustache.render(input_template); + $('[data-role-input]').html(input_render); + console.log("SplitHandler initialized"); + } + + return { + init: init, + set_pane_types: set_pane_types, + dynamic_split: dynamic_split, + split_panes: split_panes, + } +})(); diff --git a/evennia/web/webclient/static/webclient/js/webclient_gui.js b/evennia/web/webclient/static/webclient/js/webclient_gui.js index 57d9b0b7c0..b4e5168769 100644 --- a/evennia/web/webclient/static/webclient/js/webclient_gui.js +++ b/evennia/web/webclient/static/webclient/js/webclient_gui.js @@ -17,6 +17,10 @@ var options = {}; + +var known_types = new Array(); + known_types.push('help'); + // // GUI Elements // @@ -106,6 +110,7 @@ function togglePopup(dialogname, content) { // Grab text from inputline and send to Evennia function doSendText() { + console.log("sending text"); if (!Evennia.isConnected()) { var reconnect = confirm("Not currently connected. Reconnect?"); if (reconnect) { @@ -158,6 +163,10 @@ function onKeydown (event) { var code = event.which; var history_entry = null; var inputfield = $("#inputfield"); + if (code === 9) { + return; + } + inputfield.focus(); if (code === 13) { // Enter key sends text @@ -205,64 +214,68 @@ function onKeyPress (event) { } var resizeInputField = function () { - var min_height = 50; - var max_height = 300; - var prev_text_len = 0; + return function() { + var wrapper = $("#inputform") + var input = $("#inputcontrol") + var prompt = $("#prompt") - // Check to see if we should change the height of the input area - return function () { - var inputfield = $("#inputfield"); - var scrollh = inputfield.prop("scrollHeight"); - var clienth = inputfield.prop("clientHeight"); - var newh = 0; - var curr_text_len = inputfield.val().length; - - if (scrollh > clienth && scrollh <= max_height) { - // Need to make it bigger - newh = scrollh; - } - else if (curr_text_len < prev_text_len) { - // There is less text in the field; try to make it smaller - // To avoid repaints, we draw the text in an offscreen element and - // determine its dimensions. - var sizer = $('#inputsizer') - .css("width", inputfield.prop("clientWidth")) - .text(inputfield.val()); - newh = sizer.prop("scrollHeight"); - } - - if (newh != 0) { - newh = Math.min(newh, max_height); - if (clienth != newh) { - inputfield.css("height", newh + "px"); - doWindowResize(); - } - } - prev_text_len = curr_text_len; + input.height(wrapper.height() - (input.offset().top - wrapper.offset().top)); } }(); // Handle resizing of client function doWindowResize() { - var formh = $('#inputform').outerHeight(true); - var message_scrollh = $("#messagewindow").prop("scrollHeight"); - $("#messagewindow") - .css({"bottom": formh}) // leave space for the input form - .scrollTop(message_scrollh); // keep the output window scrolled to the bottom + resizeInputField(); + var resizable = $("[data-update-append]"); + var parents = resizable.closest(".split") + parents.animate({ + scrollTop: parents.prop("scrollHeight") + }, 0); } // Handle text coming from the server function onText(args, kwargs) { - // append message to previous ones, then scroll so latest is at - // the bottom. Send 'cls' kwarg to modify the output class. - var renderto = "main"; - if (kwargs["type"] == "help") { - if (("helppopup" in options) && (options["helppopup"])) { - renderto = "#helpdialog"; + var use_default_pane = true; + + if ( kwargs && 'type' in kwargs ) { + var msgtype = kwargs['type']; + if ( ! known_types.includes(msgtype) ) { + // this is a new output type that can be mapped to panes + console.log('detected new output type: ' + msgtype) + known_types.push(msgtype); + } + + if ( msgtype == 'help' ) { + if (("helppopup" in options) && (options["helppopup"])) { + openPopup("#helpdialog", args[0]); + return; + } + // fall through to the default output + + } else { + // pass this message to each pane that has this msgtype mapped + if( SplitHandler ) { + for ( var key in SplitHandler.split_panes) { + var pane = SplitHandler.split_panes[key]; + console.log(pane); + // is this message type mapped to this pane? + if ( (pane['types'].length > 0) && pane['types'].includes(msgtype) ) { + // yes, so append/replace this pane's inner div with this message + if ( pane['update_method'] == 'replace' ) { + $('#'+key).html(args[0]) + } else { + $('#'+key).append(args[0]).animate({ scrollTop: document.getElementById("#"+key).scrollHeight }, 0); + } + // record sending this message to a pane, no need to update the default div + use_default_pane = false; + } + } + } } } - if (renderto == "main") { + // append message to default pane, then scroll so latest is at the bottom. + if(use_default_pane) { var mwin = $("#messagewindow"); var cls = kwargs == null ? 'out' : kwargs['cls']; mwin.append("
" + args[0] + "
"); @@ -271,8 +284,6 @@ function onText(args, kwargs) { }, 0); onNewLine(args[0], null); - } else { - openPopup(renderto, args[0]); } } @@ -377,7 +388,10 @@ function onNewLine(text, originator) { document.title = "(" + unread + ") " + originalTitle; if ("Notification" in window){ if (("notification_popup" in options) && (options["notification_popup"])) { - Notification.requestPermission().then(function(result) { + // There is a Promise-based API for this, but it’s not supported + // in Safari and some older browsers: + // https://developer.mozilla.org/en-US/docs/Web/API/Notification/requestPermission#Browser_compatibility + Notification.requestPermission(function(result) { if(result === "granted") { var title = originalTitle === "" ? "Evennia" : originalTitle; var options = { @@ -427,6 +441,81 @@ function doStartDragDialog(event) { $(document).bind("mouseup", undrag); } + +function onSplitDialogClose() { + var pane = $("input[name=pane]:checked").attr("value"); + var direction = $("input[name=direction]:checked").attr("value"); + var flow1 = $("input[name=flow1]:checked").attr("value"); + var flow2 = $("input[name=flow2]:checked").attr("value"); + + SplitHandler.dynamic_split( pane, direction, flow1, flow2 ); + + closePopup("#splitdialog"); +} + + +function onSplitDialog() { + var dialog = $("#splitdialogcontent"); + dialog.empty(); + + dialog.append("

Split?

"); + dialog.append(' top/bottom
'); + dialog.append(' side-by-side
'); + + dialog.append("

Split Which Pane?

"); + for ( var pane in SplitHandler.split_panes ) { + dialog.append(''+ pane +'
'); + } + + dialog.append("

New First Pane Flow

"); + dialog.append('append
'); + dialog.append('replace
'); + + dialog.append("

New Second Pane Flow

"); + dialog.append('append
'); + dialog.append('replace
'); + + dialog.append('
Split It
'); + + $("#splitclose").bind("click", onSplitDialogClose); + + openPopup("#splitdialog"); +} + +function onPaneControlDialogClose() { + var pane = $("input[name=pane]:checked").attr("value"); + + var types = new Array; + $('#splitdialogcontent input[type=checkbox]:checked').each(function() { + types.push( $(this).attr('value') ); + }); + + SplitHandler.set_pane_types( pane, types ); + + closePopup("#splitdialog"); +} + +function onPaneControlDialog() { + var dialog = $("#splitdialogcontent"); + dialog.empty(); + + dialog.append("

Set Which Pane?

"); + for ( var pane in SplitHandler.split_panes ) { + dialog.append(''+ pane +'
'); + } + + dialog.append("

Which content types?

"); + for ( var type in known_types ) { + dialog.append(''+ known_types[type] +'
'); + } + + dialog.append('
Make It So
'); + + $("#paneclose").bind("click", onPaneControlDialogClose); + + openPopup("#splitdialog"); +} + // // Register Events // @@ -434,6 +523,16 @@ function doStartDragDialog(event) { // Event when client finishes loading $(document).ready(function() { + if( SplitHandler ) { + SplitHandler.init(); + SplitHandler.split_panes['main-sub'] = {'types': ['help'], 'update_method': 'replace'}; + $("#splitbutton").bind("click", onSplitDialog); + $("#panebutton").bind("click", onPaneControlDialog); + } else { + $("#splitbutton").hide(); + $("#panebutton").hide(); + } + if ("Notification" in window) { Notification.requestPermission(); } @@ -450,7 +549,7 @@ $(document).ready(function() { //$(document).on("visibilitychange", onVisibilityChange); - $("#inputfield").bind("resize", doWindowResize) + $("[data-role-input]").bind("resize", doWindowResize) .keypress(onKeyPress) .bind("paste", resizeInputField) .bind("cut", resizeInputField); @@ -503,6 +602,7 @@ $(document).ready(function() { }, 60000*3 ); + console.log("Completed GUI setup"); }); diff --git a/evennia/web/webclient/templates/webclient/base.html b/evennia/web/webclient/templates/webclient/base.html index f5f47b230f..f31e4c89f1 100644 --- a/evennia/web/webclient/templates/webclient/base.html +++ b/evennia/web/webclient/templates/webclient/base.html @@ -14,55 +14,16 @@ JQuery available. + + + + + - - - - {% block jquery_import %} - - {% endblock %} - - - - - - - - - {% block guilib_import %} - - {% endblock %} - - + @@ -79,17 +40,69 @@ JQuery available. web browser supporting javascript.

This error could also be due to not being able to access the online jQuery javascript library.

- -
-
+
{% block client %} {% endblock %}
+ + + {% block jquery_import %} + + {% endblock %} + + + + + + + + + + + + + + + + + + {% block guilib_import %} + + {% endblock %} + + + + + {% block scripts %} + {% endblock %} diff --git a/evennia/web/webclient/templates/webclient/webclient.html b/evennia/web/webclient/templates/webclient/webclient.html index 1c641bffb0..2b138cb8bd 100644 --- a/evennia/web/webclient/templates/webclient/webclient.html +++ b/evennia/web/webclient/templates/webclient/webclient.html @@ -8,20 +8,30 @@ {% block client %} +
+ + + +
-
-
- -
-
-
-
-
- - + +
+
+
+
+
+ + +
+
+ + +
+
Split Pane×
+
+
-
@@ -47,4 +57,29 @@
+ + + + + + +{% endblock %} +{% block scripts %} {% endblock %} From 8680708d52f8756aeb0bf5272ee309a02aee248e Mon Sep 17 00:00:00 2001 From: friarzen Date: Sat, 17 Mar 2018 21:50:11 +0000 Subject: [PATCH 055/208] Example of how to tag msg() with a type --- evennia/commands/default/general.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/evennia/commands/default/general.py b/evennia/commands/default/general.py index 2f4c51a227..aef5309d32 100644 --- a/evennia/commands/default/general.py +++ b/evennia/commands/default/general.py @@ -71,7 +71,7 @@ class CmdLook(COMMAND_DEFAULT_CLASS): target = caller.search(self.args) if not target: return - self.msg(caller.at_look(target)) + self.msg((caller.at_look(target), {'type':'look'}), options=None) class CmdNick(COMMAND_DEFAULT_CLASS): From 60578dc5576dd401ad166bd77c57431474ff77cc Mon Sep 17 00:00:00 2001 From: Griatch Date: Sat, 17 Mar 2018 23:29:16 +0100 Subject: [PATCH 056/208] Add typeclass/list to list all available typeclasses --- evennia/commands/default/building.py | 23 ++++++++++++++++++++++- evennia/utils/utils.py | 22 +++++++++++++++++++++- 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/evennia/commands/default/building.py b/evennia/commands/default/building.py index 9ea9707498..9e8199d546 100644 --- a/evennia/commands/default/building.py +++ b/evennia/commands/default/building.py @@ -10,7 +10,7 @@ from evennia.objects.models import ObjectDB from evennia.locks.lockhandler import LockException from evennia.commands.cmdhandler import get_and_merge_cmdsets from evennia.utils import create, utils, search -from evennia.utils.utils import inherits_from, class_from_module +from evennia.utils.utils import inherits_from, class_from_module, get_all_typeclasses from evennia.utils.eveditor import EvEditor from evennia.utils.evmore import EvMore from evennia.utils.spawner import (spawn, search_prototype, list_prototypes, @@ -1702,6 +1702,7 @@ class CmdTypeclass(COMMAND_DEFAULT_CLASS): object - basically making this a new clean object. force - change to the typeclass also if the object already has a typeclass of the same name. + list - show available typeclasses. Example: @type button = examples.red_button.RedButton @@ -1733,6 +1734,26 @@ class CmdTypeclass(COMMAND_DEFAULT_CLASS): caller = self.caller + if 'list' in self.switches: + tclasses = get_all_typeclasses() + print(list(tclasses.keys())) + contribs = [key for key in sorted(tclasses) + if key.startswith("evennia.contrib")] or [""] + core = [key for key in sorted(tclasses) + if key.startswith("evennia") and key not in contribs] or [""] + game = [key for key in sorted(tclasses) + if not key.startswith("evennia")] or [""] + string = ("|wCore typeclasses|n\n" + " {core}\n" + "|wLoaded Contrib typeclasses|n\n" + " {contrib}\n" + "|wGame-dir typeclasses|n\n" + " {game}").format(core="\n ".join(core), + contrib="\n ".join(contribs), + game="\n ".join(game)) + caller.msg(string) + return + if not self.args: caller.msg("Usage: %s [= typeclass]" % self.cmdstring) return diff --git a/evennia/utils/utils.py b/evennia/utils/utils.py index 10621f0feb..a8d2171f75 100644 --- a/evennia/utils/utils.py +++ b/evennia/utils/utils.py @@ -20,12 +20,13 @@ import textwrap import random from os.path import join as osjoin from importlib import import_module -from inspect import ismodule, trace, getmembers, getmodule +from inspect import ismodule, trace, getmembers, getmodule, getmro from collections import defaultdict, OrderedDict from twisted.internet import threads, reactor, task from django.conf import settings from django.utils import timezone from django.utils.translation import ugettext as _ +from django.apps import apps from evennia.utils import logger _MULTIMATCH_TEMPLATE = settings.SEARCH_MULTIMATCH_TEMPLATE @@ -1879,3 +1880,22 @@ def get_game_dir_path(): else: os.chdir(os.pardir) raise RuntimeError("server/conf/settings.py not found: Must start from inside game dir.") + + +def get_all_typeclasses(): + """ + List available typeclasses from all available modules. + + Returns: + typeclasses (dict): On the form {"typeclass.path": typeclass, ...} + + Notes: + This will dynamicall retrieve all abstract django models inheriting at any distance + from the TypedObject base (aka a Typeclass) so it will work fine with any custom + classes being added. + + """ + from evennia.typeclasses.models import TypedObject + typeclasses = {"{}.{}".format(model.__module__, model.__name__): model + for model in apps.get_models() if TypedObject in getmro(model)} + return typeclasses From 856c889e3f62dad7af489384fe5b212fd14d9d20 Mon Sep 17 00:00:00 2001 From: friarzen Date: Sun, 18 Mar 2018 00:33:08 +0000 Subject: [PATCH 057/208] move the initial settings for the main split where it belongs --- evennia/web/webclient/static/webclient/js/splithandler.js | 2 ++ evennia/web/webclient/static/webclient/js/webclient_gui.js | 1 - 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/evennia/web/webclient/static/webclient/js/splithandler.js b/evennia/web/webclient/static/webclient/js/splithandler.js index 56890009e5..7ba8e45e17 100644 --- a/evennia/web/webclient/static/webclient/js/splithandler.js +++ b/evennia/web/webclient/static/webclient/js/splithandler.js @@ -63,6 +63,8 @@ var SplitHandler = (function () { minSize: [50,50], }); + split_panes['main-sub'] = {'types': [], 'update_method': 'append'}; + var input_render = Mustache.render(input_template); $('[data-role-input]').html(input_render); console.log("SplitHandler initialized"); diff --git a/evennia/web/webclient/static/webclient/js/webclient_gui.js b/evennia/web/webclient/static/webclient/js/webclient_gui.js index b4e5168769..d07239039f 100644 --- a/evennia/web/webclient/static/webclient/js/webclient_gui.js +++ b/evennia/web/webclient/static/webclient/js/webclient_gui.js @@ -525,7 +525,6 @@ $(document).ready(function() { if( SplitHandler ) { SplitHandler.init(); - SplitHandler.split_panes['main-sub'] = {'types': ['help'], 'update_method': 'replace'}; $("#splitbutton").bind("click", onSplitDialog); $("#panebutton").bind("click", onPaneControlDialog); } else { From 665ba8d0f42dfc3a5f004000fbd1944f398ac9be Mon Sep 17 00:00:00 2001 From: friarzen Date: Sun, 18 Mar 2018 00:45:23 +0000 Subject: [PATCH 058/208] Make the initial login 'look' match CmdLook --- evennia/objects/objects.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/evennia/objects/objects.py b/evennia/objects/objects.py index f583570707..a588af0000 100644 --- a/evennia/objects/objects.py +++ b/evennia/objects/objects.py @@ -1762,7 +1762,7 @@ class DefaultCharacter(DefaultObject): """ self.msg("\nYou become |c%s|n.\n" % self.name) - self.msg(self.at_look(self.location)) + self.msg((self.at_look(self.location), {'type':'look'}), options = None) def message(obj, from_obj): obj.msg("%s has entered the game." % self.get_display_name(obj), from_obj=from_obj) From 6b383c863b82dd3295ed859b860a0490e4bdc9d4 Mon Sep 17 00:00:00 2001 From: Griatch Date: Sun, 18 Mar 2018 08:21:40 +0100 Subject: [PATCH 059/208] Expand typeclass/show to view typeclass docstrings --- evennia/commands/default/building.py | 35 ++++++++++++++++++++++++++-- 1 file changed, 33 insertions(+), 2 deletions(-) diff --git a/evennia/commands/default/building.py b/evennia/commands/default/building.py index beaaeba5a5..a948da14a9 100644 --- a/evennia/commands/default/building.py +++ b/evennia/commands/default/building.py @@ -1700,11 +1700,13 @@ class CmdTypeclass(COMMAND_DEFAULT_CLASS): @typeclass[/switch] [= typeclass.path] @type '' @parent '' + @typeclass/list/show [typeclass.path] @swap - this is a shorthand for using /force/reset flags. @update - this is a shorthand for using the /force/reload flag. Switch: - show - display the current typeclass of object (default) + show, examine - display the current typeclass of object (default) or, if + given a typeclass path, show the docstring of that typeclass. update - *only* re-run at_object_creation on this object meaning locks or other properties set later may remain. reset - clean out *all* the attributes and properties on the @@ -1712,6 +1714,8 @@ class CmdTypeclass(COMMAND_DEFAULT_CLASS): force - change to the typeclass also if the object already has a typeclass of the same name. list - show available typeclasses. + + Example: @type button = examples.red_button.RedButton @@ -1767,6 +1771,33 @@ class CmdTypeclass(COMMAND_DEFAULT_CLASS): caller.msg("Usage: %s [= typeclass]" % self.cmdstring) return + if "show" in self.switches or "examine" in self.switches: + oquery = self.lhs + obj = caller.search(oquery, quiet=True) + if not obj: + # no object found to examine, see if it's a typeclass-path instead + tclasses = get_all_typeclasses() + matches = [(key, tclass) + for key, tclass in tclasses.items() if key.endswith(oquery)] + nmatches = len(matches) + if nmatches > 1: + caller.msg("Multiple typeclasses found matching {}:\n {}".format( + oquery, "\n ".join(tup[0] for tup in matches))) + elif not matches: + caller.msg("No object or typeclass path found to match '{}'".format(oquery)) + else: + # one match found + caller.msg("Docstring for typeclass '{}':\n{}".format( + oquery, matches[0][1].__doc__)) + else: + # do the search again to get the error handling in case of multi-match + obj = caller.search(oquery) + if not obj: + return + caller.msg("{}'s current typeclass is '{}.{}'".format( + obj.name, obj.__class__.__module__, obj.__class__.__name__)) + return + # get object to swap on obj = caller.search(self.lhs) if not obj: @@ -1779,7 +1810,7 @@ class CmdTypeclass(COMMAND_DEFAULT_CLASS): new_typeclass = self.rhs or obj.path - if "show" in self.switches: + if "show" in self.switches or "examine" in self.switches: string = "%s's current typeclass is %s." % (obj.name, obj.__class__) caller.msg(string) return From b75cd81eda87f33e582a26cd537c88dc46957f43 Mon Sep 17 00:00:00 2001 From: Griatch Date: Sun, 18 Mar 2018 12:53:38 +0100 Subject: [PATCH 060/208] Fix unit tests --- evennia/commands/default/building.py | 23 ++++---- evennia/commands/default/tests.py | 8 ++- evennia/utils/spawner.py | 81 ++++++++++++++-------------- 3 files changed, 62 insertions(+), 50 deletions(-) diff --git a/evennia/commands/default/building.py b/evennia/commands/default/building.py index a948da14a9..c2200470c3 100644 --- a/evennia/commands/default/building.py +++ b/evennia/commands/default/building.py @@ -14,8 +14,8 @@ from evennia.utils.utils import inherits_from, class_from_module, get_all_typecl from evennia.utils.eveditor import EvEditor from evennia.utils.evmore import EvMore from evennia.utils.spawner import (spawn, search_prototype, list_prototypes, - store_prototype, build_metaproto, validate_prototype, - delete_prototype, PermissionError) + save_db_prototype, build_metaproto, validate_prototype, + delete_db_prototype, PermissionError) from evennia.utils.ansi import raw COMMAND_DEFAULT_CLASS = class_from_module(settings.COMMAND_DEFAULT_CLASS) @@ -1739,6 +1739,7 @@ class CmdTypeclass(COMMAND_DEFAULT_CLASS): key = "@typeclass" aliases = ["@type", "@parent", "@swap", "@update"] + switch_options = ("show", "examine", "update", "reset", "force", "list") locks = "cmd:perm(typeclass) or perm(Builder)" help_category = "Building" @@ -1749,7 +1750,6 @@ class CmdTypeclass(COMMAND_DEFAULT_CLASS): if 'list' in self.switches: tclasses = get_all_typeclasses() - print(list(tclasses.keys())) contribs = [key for key in sorted(tclasses) if key.startswith("evennia.contrib")] or [""] core = [key for key in sorted(tclasses) @@ -1764,7 +1764,7 @@ class CmdTypeclass(COMMAND_DEFAULT_CLASS): " {game}").format(core="\n ".join(core), contrib="\n ".join(contribs), game="\n ".join(game)) - caller.msg(string) + EvMore(caller, string, exit_on_lastpage=True) return if not self.args: @@ -2841,7 +2841,7 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): """ key = "@spawn" - switch_options = ("noloc", ) + switch_options = ("noloc", "search", "list", "show", "save", "delete", "menu") locks = "cmd:perm(spawn) or perm(Builder)" help_category = "Building" @@ -2912,7 +2912,7 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): tags = [tag.strip() for tag in tags.split(",")] if tags else None EvMore(caller, unicode(list_prototypes(caller, key=key, tags=tags)), exit_on_lastpage=True) - return + return if 'show' in self.switches or 'examine' in self.switches: # the argument is a key in this case (may be a partial key) @@ -2943,7 +2943,7 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): caller.msg("|rDeletion cancelled.|n") return try: - success = delete_prototype(caller, self.args) + success = delete_db_prototype(caller, self.args) except PermissionError as err: caller.msg("|rError deleting:|R {}|n".format(err)) caller.msg("Deletion {}.".format( @@ -2961,10 +2961,12 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): # handle lhs parts = self.lhs.split(";", 3) - key, desc, tags, lockstring = "", "", [], "" + key, desc, tags, lockstring = ( + "", "User-created prototype", ["user-created"], + "edit:id({}) or perm(Admin); use:all()".format(caller.id)) nparts = len(parts) if nparts == 1: - key = parts.strip() + key = parts[0].strip() elif nparts == 2: key, desc = (part.strip() for part in parts) elif nparts == 3: @@ -3000,7 +3002,7 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): # all seems ok. Try to save. try: - store_prototype(caller, key, prototype, desc=desc, tags=tags, locks=lockstring) + save_db_prototype(caller, key, prototype, desc=desc, tags=tags, locks=lockstring) except PermissionError as err: caller.msg("|rError saving:|R {}|n".format(err)) return @@ -3038,6 +3040,7 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): metaproto = metaprotos[0] if not caller.locks.check_lockstring(caller, metaproto.locks, access_type='use'): caller.msg("You don't have access to use this prototype.") + print("spawning2 {}:{} - {}".format(self.cmdstring, self.args, prototype)) return prototype = metaproto.prototype diff --git a/evennia/commands/default/tests.py b/evennia/commands/default/tests.py index bf01ac3039..fe738a3e07 100644 --- a/evennia/commands/default/tests.py +++ b/evennia/commands/default/tests.py @@ -27,6 +27,7 @@ from evennia.utils import ansi, utils from evennia.server.sessionhandler import SESSIONS from evennia import search_object from evennia import DefaultObject, DefaultCharacter +from evennia.utils import spawner # set up signal here since we are not starting the server @@ -390,8 +391,10 @@ class TestBuilding(CommandTest): self.assertEqual(goblin.location, spawnLoc) goblin.delete() + spawner.save_db_prototype(self.char1, "ball", {'key': 'Ball', 'prototype': 'GOBLIN'}) + # Tests "@spawn " - self.call(building.CmdSpawn(), "'BALL'", "Spawned Ball") + self.call(building.CmdSpawn(), "ball", "Spawned Ball") ball = getObject(self, "Ball") self.assertEqual(ball.location, self.char1.location) self.assertIsInstance(ball, DefaultObject) @@ -416,6 +419,9 @@ class TestBuilding(CommandTest): # test calling spawn with an invalid prototype. self.call(building.CmdSpawn(), "'NO_EXIST'", "No prototype named 'NO_EXIST'") + # Test listing commands + self.call(building.CmdSpawn(), "/list", "| Key ") + class TestComms(CommandTest): diff --git a/evennia/utils/spawner.py b/evennia/utils/spawner.py index 2a003069a4..6426aa0acc 100644 --- a/evennia/utils/spawner.py +++ b/evennia/utils/spawner.py @@ -109,17 +109,17 @@ from django.conf import settings from random import randint import evennia from evennia.objects.models import ObjectDB -from evennia.utils.utils import make_iter, all_from_module, dbid_to_obj +from evennia.utils.utils import make_iter, all_from_module, dbid_to_obj, is_iter -from collections import namedtuple, defaultdict +from collections import namedtuple from evennia.scripts.scripts import DefaultScript from evennia.utils.create import create_script from evennia.utils.evtable import EvTable _CREATE_OBJECT_KWARGS = ("key", "location", "home", "destination") -_READONLY_PROTOTYPES = {} -_READONLY_PROTOTYPE_MODULES = {} +_MODULE_PROTOTYPES = {} +_MODULE_PROTOTYPE_MODULES = {} class PermissionError(RuntimeError): @@ -133,7 +133,7 @@ for mod in settings.PROTOTYPE_MODULES: # internally we store as (key, desc, locks, tags, prototype_dict) prots = [(key, prot) for key, prot in all_from_module(mod).items() if prot and isinstance(prot, dict)] - _READONLY_PROTOTYPES.update( + _MODULE_PROTOTYPES.update( {key.lower(): MetaProto( key.lower(), prot['prototype_desc'] if 'prototype_desc' in prot else mod, @@ -142,12 +142,12 @@ for mod in settings.PROTOTYPE_MODULES: prot['prototype_tags']) if 'prototype_tags' in prot else ["base-prototype"]), prot) for key, prot in prots}) - _READONLY_PROTOTYPE_MODULES.update({tup[0]: mod for tup in prots}) + _MODULE_PROTOTYPE_MODULES.update({tup[0]: mod for tup in prots}) # Prototype storage mechanisms -class PersistentPrototype(DefaultScript): +class DbPrototype(DefaultScript): """ This stores a single prototype """ @@ -161,10 +161,10 @@ def build_metaproto(key, desc, locks, tags, prototype): Create a metaproto from combinant parts. """ - return MetaProto(key, desc, make_iter(locks), tags, dict(prototype)) + return MetaProto(key, desc, ";".join(locks) if is_iter(locks) else locks, tags, dict(prototype)) -def store_prototype(caller, key, prototype, desc="", tags=None, locks="", delete=False): +def save_db_prototype(caller, key, prototype, desc="", tags=None, locks="", delete=False): """ Store a prototype persistently. @@ -176,7 +176,7 @@ def store_prototype(caller, key, prototype, desc="", tags=None, locks="", delete prototype (dict): Prototype dict. desc (str, optional): Description of prototype, to use in listing. tags (list, optional): Tag-strings to apply to prototype. These are always - applied with the 'persistent_prototype' category. + applied with the 'db_prototype' category. locks (str, optional): Locks to apply to this prototype. Used locks are 'use' and 'edit' delete (bool, optional): Delete an existing prototype identified by 'key'. @@ -192,14 +192,14 @@ def store_prototype(caller, key, prototype, desc="", tags=None, locks="", delete key_orig = key key = key.lower() locks = locks if locks else "use:all();edit:id({}) or perm(Admin)".format(caller.id) - tags = [(tag, "persistent_prototype") for tag in make_iter(tags)] + tags = [(tag, "db_prototype") for tag in make_iter(tags)] - if key in _READONLY_PROTOTYPES: - mod = _READONLY_PROTOTYPE_MODULES.get(key, "N/A") + if key in _MODULE_PROTOTYPES: + mod = _MODULE_PROTOTYPE_MODULES.get(key, "N/A") raise PermissionError("{} is a read-only prototype " "(defined as code in {}).".format(key_orig, mod)) - stored_prototype = PersistentPrototype.objects.filter(db_key=key) + stored_prototype = DbPrototype.objects.filter(db_key=key) if stored_prototype: # edit existing prototype @@ -227,12 +227,12 @@ def store_prototype(caller, key, prototype, desc="", tags=None, locks="", delete else: # create a new prototype stored_prototype = create_script( - PersistentPrototype, key=key, desc=desc, persistent=True, + DbPrototype, key=key, desc=desc, persistent=True, locks=locks, tags=tags, attributes=[("prototype", prototype)]) return stored_prototype -def delete_prototype(caller, key): +def delete_db_prototype(caller, key): """ Delete a stored prototype @@ -245,21 +245,21 @@ def delete_prototype(caller, key): PermissionError: If 'edit' lock was not passed. """ - return store_prototype(caller, key, None, delete=True) + return save_db_prototype(caller, key, None, delete=True) -def search_persistent_prototype(key=None, tags=None, return_metaprotos=False): +def search_db_prototype(key=None, tags=None, return_metaprotos=False): """ Find persistent (database-stored) prototypes based on key and/or tags. Kwargs: key (str): An exact or partial key to query for. tags (str or list): Tag key or keys to query for. These - will always be applied with the 'persistent_protototype' + will always be applied with the 'db_protototype' tag category. return_metaproto (bool): Return results as metaprotos. Return: - matches (queryset or list): All found PersistentPrototypes. If `return_metaprotos` + matches (queryset or list): All found DbPrototypes. If `return_metaprotos` is set, return a list of MetaProtos. Note: @@ -269,22 +269,22 @@ def search_persistent_prototype(key=None, tags=None, return_metaprotos=False): if tags: # exact match on tag(s) tags = make_iter(tags) - tag_categories = ["persistent_prototype" for _ in tags] - matches = PersistentPrototype.objects.get_by_tag(tags, tag_categories) + tag_categories = ["db_prototype" for _ in tags] + matches = DbPrototype.objects.get_by_tag(tags, tag_categories) else: - matches = PersistentPrototype.objects.all() + matches = DbPrototype.objects.all() if key: # exact or partial match on key matches = matches.filter(db_key=key) or matches.filter(db_key__icontains=key) if return_metaprotos: return [build_metaproto(match.key, match.desc, match.locks.all(), - match.tags.get(category="persistent_prototype", return_list=True), + match.tags.get(category="db_prototype", return_list=True), match.attributes.get("prototype")) for match in matches] return matches -def search_readonly_prototype(key=None, tags=None): +def search_module_prototype(key=None, tags=None): """ Find read-only prototypes, defined in modules. @@ -301,10 +301,10 @@ def search_readonly_prototype(key=None, tags=None): if tags: # use tags to limit selection tagset = set(tags) - matches = {key: metaproto for key, metaproto in _READONLY_PROTOTYPES.items() + matches = {key: metaproto for key, metaproto in _MODULE_PROTOTYPES.items() if tagset.intersection(metaproto.tags)} else: - matches = _READONLY_PROTOTYPES + matches = _MODULE_PROTOTYPES if key: if key in matches: @@ -324,7 +324,7 @@ def search_prototype(key=None, tags=None, return_meta=True): Kwargs: key (str): An exact or partial key to query for. tags (str or list): Tag key or keys to query for. These - will always be applied with the 'persistent_protototype' + will always be applied with the 'db_protototype' tag category. return_meta (bool): If False, only return prototype dicts, if True return MetaProto namedtuples including prototype meta info @@ -340,15 +340,15 @@ def search_prototype(key=None, tags=None, return_meta=True): be found. """ - readonly_prototypes = search_readonly_prototype(key, tags) - persistent_prototypes = search_persistent_prototype(key, tags, return_metaprotos=True) + module_prototypes = search_module_prototype(key, tags) + db_prototypes = search_db_prototype(key, tags, return_metaprotos=True) - matches = persistent_prototypes + readonly_prototypes + matches = db_prototypes + module_prototypes if len(matches) > 1 and key: key = key.lower() # avoid duplicates if an exact match exist between the two types filter_matches = [mta for mta in matches if mta.key == key] - if len(filter_matches) < len(matches): + if filter_matches and len(filter_matches) < len(matches): matches = filter_matches if not return_meta: @@ -369,8 +369,7 @@ def get_protparents(): return {metaproto.key: metaproto.prototype for metaproto in metaprotos} -def list_prototypes(caller, key=None, tags=None, show_non_use=False, - show_non_edit=True, sort_tree=True): +def list_prototypes(caller, key=None, tags=None, show_non_use=False, show_non_edit=True): """ Collate a list of found prototypes based on search criteria and access. @@ -380,22 +379,27 @@ def list_prototypes(caller, key=None, tags=None, show_non_use=False, tags (str or list, optional): Tag key or keys to query for. show_non_use (bool, optional): Show also prototypes the caller may not use. show_non_edit (bool, optional): Show also prototypes the caller may not edit. - sort_tree (bool, optional): Order prototypes by inheritance tree. Returns: table (EvTable or None): An EvTable representation of the prototypes. None if no prototypes were found. """ + # this allows us to pass lists of empty strings + tags = [tag for tag in make_iter(tags) if tag] + # get metaprotos for readonly and db-based prototypes - metaprotos = search_readonly_prototype(key, tags) - metaprotos += search_persistent_prototype(key, tags, return_metaprotos=True) + metaprotos = search_module_prototype(key, tags) + metaprotos += search_db_prototype(key, tags, return_metaprotos=True) # get use-permissions of readonly attributes (edit is always False) prototypes = [ (metaproto.key, metaproto.desc, ("{}/N".format('Y' - if caller.locks.check_lockstring(caller, metaproto.locks, access_type='use') else 'N')), + if caller.locks.check_lockstring( + caller, + metaproto.locks, + access_type='use') else 'N')), ",".join(metaproto.tags)) for metaproto in sorted(metaprotos, key=lambda o: o.key)] @@ -642,7 +646,6 @@ def spawn(*prototypes, **kwargs): return _batch_create_object(*objsparams) - # Testing if __name__ == "__main__": From 2689beedae6a68650da8044bd04f5521dd15e844 Mon Sep 17 00:00:00 2001 From: Griatch Date: Sun, 18 Mar 2018 16:31:01 +0100 Subject: [PATCH 061/208] Add lockhandler.append to update lock string --- evennia/commands/default/building.py | 5 ++- evennia/locks/lockhandler.py | 48 +++++++++++++++++++++++----- evennia/utils/spawner.py | 7 +++- 3 files changed, 50 insertions(+), 10 deletions(-) diff --git a/evennia/commands/default/building.py b/evennia/commands/default/building.py index c2200470c3..c2aaa16a4c 100644 --- a/evennia/commands/default/building.py +++ b/evennia/commands/default/building.py @@ -3002,7 +3002,10 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): # all seems ok. Try to save. try: - save_db_prototype(caller, key, prototype, desc=desc, tags=tags, locks=lockstring) + prot = save_db_prototype(caller, key, prototype, desc=desc, tags=tags, locks=lockstring) + prot.locks.append("edit", "perm(Admin)") + if not prot.locks.get("use"): + prot.locks.add("use:all()") except PermissionError as err: caller.msg("|rError saving:|R {}|n".format(err)) return diff --git a/evennia/locks/lockhandler.py b/evennia/locks/lockhandler.py index b8801f9655..6b1a30ab03 100644 --- a/evennia/locks/lockhandler.py +++ b/evennia/locks/lockhandler.py @@ -421,6 +421,28 @@ class LockHandler(object): self._cache_locks(self.obj.lock_storage) self.cache_lock_bypass(self.obj) + def append(self, access_type, lockstring, op='or'): + """ + Append a lock definition to access_type if it doesn't already exist. + + Args: + access_type (str): Access type. + lockstring (str): A valid lockstring, without the operator to + link it to an eventual existing lockstring. + op (str): An operator 'and', 'or', 'and not', 'or not' used + for appending the lockstring to an existing access-type. + Note: + The most common use of this method is for use in commands where + the user can specify their own lockstrings. This method allows + the system to auto-add things like Admin-override access. + + """ + old_lockstring = self.get(access_type) + if not lockstring.strip().lower() in old_lockstring.lower(): + lockstring = "{old} {op} {new}".format( + old=old_lockstring, op=op, new=lockstring.strip()) + self.add(lockstring) + def check(self, accessing_obj, access_type, default=False, no_superuser_bypass=False): """ Checks a lock of the correct type by passing execution off to @@ -459,9 +481,13 @@ class LockHandler(object): return True except AttributeError: # happens before session is initiated. - if not no_superuser_bypass and ((hasattr(accessing_obj, 'is_superuser') and accessing_obj.is_superuser) or - (hasattr(accessing_obj, 'account') and hasattr(accessing_obj.account, 'is_superuser') and accessing_obj.account.is_superuser) or - (hasattr(accessing_obj, 'get_account') and (not accessing_obj.get_account() or accessing_obj.get_account().is_superuser))): + if not no_superuser_bypass and ( + (hasattr(accessing_obj, 'is_superuser') and accessing_obj.is_superuser) or + (hasattr(accessing_obj, 'account') and + hasattr(accessing_obj.account, 'is_superuser') and + accessing_obj.account.is_superuser) or + (hasattr(accessing_obj, 'get_account') and + (not accessing_obj.get_account() or accessing_obj.get_account().is_superuser))): return True # no superuser or bypass -> normal lock operation @@ -469,7 +495,8 @@ class LockHandler(object): # we have a lock, test it. evalstring, func_tup, raw_string = self.locks[access_type] # execute all lock funcs in the correct order, producing a tuple of True/False results. - true_false = tuple(bool(tup[0](accessing_obj, self.obj, *tup[1], **tup[2])) for tup in func_tup) + true_false = tuple(bool( + tup[0](accessing_obj, self.obj, *tup[1], **tup[2])) for tup in func_tup) # the True/False tuple goes into evalstring, which combines them # with AND/OR/NOT in order to get the final result. return eval(evalstring % true_false) @@ -520,9 +547,13 @@ class LockHandler(object): if accessing_obj.locks.lock_bypass and not no_superuser_bypass: return True except AttributeError: - if no_superuser_bypass and ((hasattr(accessing_obj, 'is_superuser') and accessing_obj.is_superuser) or - (hasattr(accessing_obj, 'account') and hasattr(accessing_obj.account, 'is_superuser') and accessing_obj.account.is_superuser) or - (hasattr(accessing_obj, 'get_account') and (not accessing_obj.get_account() or accessing_obj.get_account().is_superuser))): + if no_superuser_bypass and ( + (hasattr(accessing_obj, 'is_superuser') and accessing_obj.is_superuser) or + (hasattr(accessing_obj, 'account') and + hasattr(accessing_obj.account, 'is_superuser') and + accessing_obj.account.is_superuser) or + (hasattr(accessing_obj, 'get_account') and + (not accessing_obj.get_account() or accessing_obj.get_account().is_superuser))): return True if ":" not in lockstring: lockstring = "%s:%s" % ("_dummy", lockstring) @@ -538,7 +569,8 @@ class LockHandler(object): else: # if no access types was given and multiple locks were # embedded in the lockstring we assume all must be true - return all(self._eval_access_type(accessing_obj, locks, access_type) for access_type in locks) + return all(self._eval_access_type( + accessing_obj, locks, access_type) for access_type in locks) def _test(): diff --git a/evennia/utils/spawner.py b/evennia/utils/spawner.py index 6426aa0acc..ed92dfadd5 100644 --- a/evennia/utils/spawner.py +++ b/evennia/utils/spawner.py @@ -161,7 +161,12 @@ def build_metaproto(key, desc, locks, tags, prototype): Create a metaproto from combinant parts. """ - return MetaProto(key, desc, ";".join(locks) if is_iter(locks) else locks, tags, dict(prototype)) + if locks: + locks = (";".join(locks) if is_iter(locks) else locks) + else: + locks = [] + prototype = dict(prototype) if prototype else {} + return MetaProto(key, desc, locks, tags, dict(prototype)) def save_db_prototype(caller, key, prototype, desc="", tags=None, locks="", delete=False): From 1ce077d8312eadad278742342050fb2898941e8b Mon Sep 17 00:00:00 2001 From: Griatch Date: Sun, 18 Mar 2018 17:28:52 +0100 Subject: [PATCH 062/208] Further stabilizing of spawner storage mechanism and error checking --- evennia/commands/default/building.py | 39 +++++++++++++------ evennia/locks/lockhandler.py | 56 ++++++++++++++++++++++------ evennia/utils/spawner.py | 6 +++ 3 files changed, 79 insertions(+), 22 deletions(-) diff --git a/evennia/commands/default/building.py b/evennia/commands/default/building.py index c2aaa16a4c..94362ec58e 100644 --- a/evennia/commands/default/building.py +++ b/evennia/commands/default/building.py @@ -2229,12 +2229,15 @@ class CmdExamine(ObjManipCommand): else: things.append(content) if exits: - string += "\n|wExits|n: %s" % ", ".join(["%s(%s)" % (exit.name, exit.dbref) for exit in exits]) + string += "\n|wExits|n: %s" % ", ".join( + ["%s(%s)" % (exit.name, exit.dbref) for exit in exits]) if pobjs: - string += "\n|wCharacters|n: %s" % ", ".join(["|c%s|n(%s)" % (pobj.name, pobj.dbref) for pobj in pobjs]) + string += "\n|wCharacters|n: %s" % ", ".join( + ["|c%s|n(%s)" % (pobj.name, pobj.dbref) for pobj in pobjs]) if things: - string += "\n|wContents|n: %s" % ", ".join(["%s(%s)" % (cont.name, cont.dbref) for cont in obj.contents - if cont not in exits and cont not in pobjs]) + string += "\n|wContents|n: %s" % ", ".join( + ["%s(%s)" % (cont.name, cont.dbref) for cont in obj.contents + if cont not in exits and cont not in pobjs]) separator = "-" * _DEFAULT_WIDTH # output info return '%s\n%s\n%s' % (separator, string.strip(), separator) @@ -2961,9 +2964,6 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): # handle lhs parts = self.lhs.split(";", 3) - key, desc, tags, lockstring = ( - "", "User-created prototype", ["user-created"], - "edit:id({}) or perm(Admin); use:all()".format(caller.id)) nparts = len(parts) if nparts == 1: key = parts[0].strip() @@ -2971,11 +2971,25 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): key, desc = (part.strip() for part in parts) elif nparts == 3: key, desc, tags = (part.strip() for part in parts) - tags = [tag.strip().lower() for tag in tags.split(",")] + tags = [tag.strip().lower() for tag in tags.split(",") if tag] else: # lockstrings can itself contain ; key, desc, tags, lockstring = (part.strip() for part in parts) - tags = [tag.strip().lower() for tag in tags.split(",")] + tags = [tag.strip().lower() for tag in tags.split(",") if tag] + if not key: + caller.msg("The prototype must have a key.") + return + if not desc: + desc = "User-created prototype" + if not tags: + tags = ["user"] + if not lockstring: + lockstring = "edit:id({}) or perm(Admin); use:all()".format(caller.id) + + is_valid, err = caller.locks.validate(lockstring) + if not is_valid: + caller.msg("|rLock error|n: {}".format(err)) + return # handle rhs: prototype = _parse_prototype(self.rhs) @@ -3002,7 +3016,11 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): # all seems ok. Try to save. try: - prot = save_db_prototype(caller, key, prototype, desc=desc, tags=tags, locks=lockstring) + prot = save_db_prototype( + caller, key, prototype, desc=desc, tags=tags, locks=lockstring) + if not prot: + caller.msg("|rError saving:|R {}.|n".format(key)) + return prot.locks.append("edit", "perm(Admin)") if not prot.locks.get("use"): prot.locks.add("use:all()") @@ -3043,7 +3061,6 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): metaproto = metaprotos[0] if not caller.locks.check_lockstring(caller, metaproto.locks, access_type='use'): caller.msg("You don't have access to use this prototype.") - print("spawning2 {}:{} - {}".format(self.cmdstring, self.args, prototype)) return prototype = metaproto.prototype diff --git a/evennia/locks/lockhandler.py b/evennia/locks/lockhandler.py index 6b1a30ab03..20eb117e42 100644 --- a/evennia/locks/lockhandler.py +++ b/evennia/locks/lockhandler.py @@ -287,7 +287,7 @@ class LockHandler(object): """ self.lock_bypass = hasattr(obj, "is_superuser") and obj.is_superuser - def add(self, lockstring): + def add(self, lockstring, validate_only=False): """ Add a new lockstring to handler. @@ -296,10 +296,12 @@ class LockHandler(object): `":"`. Multiple access types should be separated by semicolon (`;`). Alternatively, a list with lockstrings. - + validate_only (bool, optional): If True, validate the lockstring but + don't actually store it. Returns: success (bool): The outcome of the addition, `False` on - error. + error. If `validate_only` is True, this will be a tuple + (bool, error), for pass/fail and a string error. """ if isinstance(lockstring, basestring): @@ -308,21 +310,41 @@ class LockHandler(object): lockdefs = [lockdef for locks in lockstring for lockdef in locks.split(";")] lockstring = ";".join(lockdefs) + err = "" # sanity checks for lockdef in lockdefs: if ':' not in lockdef: - self._log_error(_("Lock: '%s' contains no colon (:).") % lockdef) - return False + err = _("Lock: '{lockdef}' contains no colon (:).").format(lockdef=lockdef) + if validate_only: + return False, err + else: + self._log_error(err) + return False access_type, rhs = [part.strip() for part in lockdef.split(':', 1)] if not access_type: - self._log_error(_("Lock: '%s' has no access_type (left-side of colon is empty).") % lockdef) - return False + err = _("Lock: '{lockdef}' has no access_type " + "(left-side of colon is empty).").format(lockdef=lockdef) + if validate_only: + return False, err + else: + self._log_error(err) + return False if rhs.count('(') != rhs.count(')'): - self._log_error(_("Lock: '%s' has mismatched parentheses.") % lockdef) - return False + err = _("Lock: '{lockdef}' has mismatched parentheses.").format(lockdef=lockdef) + if validate_only: + return False, err + else: + self._log_error(err) + return False if not _RE_FUNCS.findall(rhs): - self._log_error(_("Lock: '%s' has no valid lock functions.") % lockdef) - return False + err = _("Lock: '{lockdef}' has no valid lock functions.").format(lockdef=lockdef) + if validate_only: + return False, err + else: + self._log_error(err) + return False + if validate_only: + return True, None # get the lock string storage_lockstring = self.obj.lock_storage if storage_lockstring: @@ -334,6 +356,18 @@ class LockHandler(object): self._save_locks() return True + def validate(self, lockstring): + """ + Validate lockstring syntactically, without saving it. + + Args: + lockstring (str): Lockstring to validate. + Returns: + valid (bool): If validation passed or not. + + """ + return self.add(lockstring, validate_only=True) + def replace(self, lockstring): """ Replaces the lockstring entirely. diff --git a/evennia/utils/spawner.py b/evennia/utils/spawner.py index ed92dfadd5..01212a38d4 100644 --- a/evennia/utils/spawner.py +++ b/evennia/utils/spawner.py @@ -197,6 +197,12 @@ def save_db_prototype(caller, key, prototype, desc="", tags=None, locks="", dele key_orig = key key = key.lower() locks = locks if locks else "use:all();edit:id({}) or perm(Admin)".format(caller.id) + + is_valid, err = caller.locks.validate(locks) + if not is_valid: + caller.msg("Lock error: {}".format(err)) + return False + tags = [(tag, "db_prototype") for tag in make_iter(tags)] if key in _MODULE_PROTOTYPES: From c05f9463ed1cbb97c84d6f4e602187d7970a9bc6 Mon Sep 17 00:00:00 2001 From: Griatch Date: Sun, 18 Mar 2018 21:16:39 +0100 Subject: [PATCH 063/208] Start adding menu OLC mechanic for spawner. The EvMenu behaves strangely; going from desc->tags by setting the description means that the back-option no longer works, giving an error that the desc-node is not defined ... --- evennia/commands/default/building.py | 26 +++- evennia/utils/evmenu.py | 4 +- evennia/utils/spawner.py | 204 ++++++++++++++++++++++++++- 3 files changed, 227 insertions(+), 7 deletions(-) diff --git a/evennia/commands/default/building.py b/evennia/commands/default/building.py index 94362ec58e..dc70e52cea 100644 --- a/evennia/commands/default/building.py +++ b/evennia/commands/default/building.py @@ -15,7 +15,7 @@ from evennia.utils.eveditor import EvEditor from evennia.utils.evmore import EvMore from evennia.utils.spawner import (spawn, search_prototype, list_prototypes, save_db_prototype, build_metaproto, validate_prototype, - delete_db_prototype, PermissionError) + delete_db_prototype, PermissionError, start_olc) from evennia.utils.ansi import raw COMMAND_DEFAULT_CLASS = class_from_module(settings.COMMAND_DEFAULT_CLASS) @@ -2806,7 +2806,8 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): @spawn/show [] @spawn/save [;desc[;tag,tag[,...][;lockstring]]] = - @spawn/menu + @spawn/menu [] + @olc - equivalent to @spawn/menu Switches: noloc - allow location to be None if not specified explicitly. Otherwise, @@ -2816,7 +2817,7 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): show, examine - inspect prototype by key. If not given, acts like list. save - save a prototype to the database. It will be listable by /list. delete - remove a prototype from database, if allowed to. - menu - manipulate prototype in a menu interface. + menu, olc - create/manipulate prototype in a menu interface. Example: @spawn GOBLIN @@ -2844,7 +2845,8 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): """ key = "@spawn" - switch_options = ("noloc", "search", "list", "show", "save", "delete", "menu") + aliases = ["@olc"] + switch_options = ("noloc", "search", "list", "show", "save", "delete", "menu", "olc") locks = "cmd:perm(spawn) or perm(Builder)" help_category = "Building" @@ -2904,6 +2906,22 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): caller = self.caller + if self.cmdstring == "olc" or 'menu' in self.switches or 'olc' in self.switches: + # OLC menu mode + metaprot = None + if self.lhs: + key = self.lhs + metaprot = search_prototype(key=key, return_meta=True) + if len(metaprot) > 1: + caller.msg("More than one match for {}:\n{}".format( + key, "\n".join(mproto.key for mproto in metaprot))) + return + elif metaprot: + # one match + metaprot = metaprot[0] + start_olc(caller, self.session, metaprot) + return + if 'search' in self.switches: # query for a key match if not self.args: diff --git a/evennia/utils/evmenu.py b/evennia/utils/evmenu.py index 3d8fb6b789..9509bbd884 100644 --- a/evennia/utils/evmenu.py +++ b/evennia/utils/evmenu.py @@ -945,9 +945,11 @@ class EvMenu(object): node (str): The formatted node to display. """ + screen_width = self._session.protocol_flags.get("SCREENWIDTH", {0: 78})[0] + nodetext_width_max = max(m_len(line) for line in nodetext.split("\n")) options_width_max = max(m_len(line) for line in optionstext.split("\n")) - total_width = max(options_width_max, nodetext_width_max) + total_width = min(screen_width, max(options_width_max, nodetext_width_max)) separator1 = "_" * total_width + "\n\n" if nodetext_width_max else "" separator2 = "\n" + "_" * total_width + "\n\n" if total_width else "" return separator1 + "|n" + nodetext + "|n" + separator2 + "|n" + optionstext diff --git a/evennia/utils/spawner.py b/evennia/utils/spawner.py index 01212a38d4..e4d403157e 100644 --- a/evennia/utils/spawner.py +++ b/evennia/utils/spawner.py @@ -109,17 +109,19 @@ from django.conf import settings from random import randint import evennia from evennia.objects.models import ObjectDB -from evennia.utils.utils import make_iter, all_from_module, dbid_to_obj, is_iter +from evennia.utils.utils import make_iter, all_from_module, dbid_to_obj, is_iter, crop from collections import namedtuple from evennia.scripts.scripts import DefaultScript from evennia.utils.create import create_script from evennia.utils.evtable import EvTable +from evennia.utils.evmenu import EvMenu _CREATE_OBJECT_KWARGS = ("key", "location", "home", "destination") _MODULE_PROTOTYPES = {} _MODULE_PROTOTYPE_MODULES = {} +_MENU_CROP_WIDTH = 15 class PermissionError(RuntimeError): @@ -156,7 +158,7 @@ class DbPrototype(DefaultScript): self.desc = "A prototype" -def build_metaproto(key, desc, locks, tags, prototype): +def build_metaproto(key='', desc='', locks='', tags=None, prototype=None): """ Create a metaproto from combinant parts. @@ -657,6 +659,204 @@ def spawn(*prototypes, **kwargs): return _batch_create_object(*objsparams) +# prototype design menu nodes + +def _get_menu_metaprot(caller): + if hasattr(caller.ndb._menutree, "olc_metaprot"): + return caller.ndb._menutree.olc_metaprot + else: + metaproto = build_metaproto(None, '', [], [], None) + caller.ndb._menutree.olc_metaprot = metaproto + caller.ndb._menutree.olc_new = True + return metaproto + + +def _set_menu_metaprot(caller, field, value): + metaprot = _get_menu_metaprot(caller) + kwargs = dict(metaprot.__dict__) + kwargs[field] = value + caller.ndb._menutree.olc_metaprot = build_metaproto(**kwargs) + + +def node_index(caller): + metaprot = _get_menu_metaprot(caller) + key = "|g{}|n".format( + crop(metaprot.key, _MENU_CROP_WIDTH)) if metaprot.key else "|rundefined, required|n" + desc = "|g{}|n".format( + crop(metaprot.desc, _MENU_CROP_WIDTH)) if metaprot.desc else "''" + tags = "|g{}|n".format( + crop(", ".join(metaprot.tags), _MENU_CROP_WIDTH)) if metaprot.tags else [] + locks = "|g{}|n".format( + crop(", ".join(metaprot.locks), _MENU_CROP_WIDTH)) if metaprot.tags else [] + prot = "|gdefined|n" if metaprot.prototype else "|rundefined, required|n" + + text = ("|c --- Prototype wizard --- |n\n" + "(make choice; q to abort, h for help)") + options = ( + {"desc": "Key ({})".format(key), "goto": "node_key"}, + {"desc": "Description ({})".format(desc), "goto": "node_desc"}, + {"desc": "Tags ({})".format(tags), "goto": "node_tags"}, + {"desc": "Locks ({})".format(locks), "goto": "node_locks"}, + {"desc": "Prototype ({})".format(prot), "goto": "node_prototype_index"}) + return text, options + + +def _node_check_key(caller, key): + old_metaprot = search_prototype(key) + olc_new = caller.ndb._menutree.olc_new + key = key.strip().lower() + if old_metaprot: + # we are starting a new prototype that matches an existing + if not caller.locks.check_lockstring(caller, old_metaprot.locks, access_type='edit'): + # return to the node_key to try another key + caller.msg("Prototype '{key}' already exists and you don't " + "have permission to edit it.".format(key=key)) + return "node_key" + elif olc_new: + # we are selecting an existing prototype to edit. Reset to index. + del caller.ndb._menutree.olc_new + caller.ndb._menutree.olc_metaprot = old_metaprot + caller.msg("Prototype already exists. Reloading.") + return "node_index" + + # continue on + _set_menu_metaprot(caller, 'key', key) + caller.msg("Key '{key}' was set.".format(key=key)) + return "node_desc" + + +def node_key(caller): + metaprot = _get_menu_metaprot(caller) + text = ["The |ckey|n must be unique and is used to find and use " + "the prototype to spawn new entities. It is not case sensitive."] + old_key = metaprot.key + if old_key: + text.append("Current key is '|y{key}|n'".format(key=old_key)) + else: + text.append("The key is currently unset.") + text.append("Enter text or make choice (q for quit, h for help)") + text = "\n".join(text) + options = ({"desc": "forward (desc)", + "goto": "node_desc"}, + {"desc": "back (index)", + "goto": "node_index"}, + {"key": "_default", + "desc": "enter a key", + "goto": _node_check_key}) + return text, options + + +def _node_check_desc(caller, desc): + desc = desc.strip() + _set_menu_metaprot(caller, 'desc', desc) + caller.msg("Description was set to '{desc}'.".format(desc=desc)) + return "node_tags" + + +def node_desc(caller): + metaprot = _get_menu_metaprot(caller) + text = ["|cDescribe|n briefly the prototype for viewing in listings."] + desc = metaprot.desc + + if desc: + text.append("The current desc is:\n\"|y{desc}|n\"".format(desc)) + else: + text.append("Description is currently unset.") + text = "\n".join(text) + options = ({"desc": "forward (tags)", + "goto": "node_tags"}, + {"desc": "back (key)", + "goto": "node_key"}, + {"key": "_default", + "desc": "enter a description", + "goto": _node_check_desc}) + + return text, options + + +def _node_check_tags(caller, tags): + tags = [part.strip().lower() for part in tags.split(",")] + _set_menu_metaprot(caller, 'tags', tags) + caller.msg("Tags {tags} were set".format(tags=tags)) + return "node_locks" + + +def node_tags(caller): + metaprot = _get_menu_metaprot(caller) + text = ["|cTags|n can be used to find prototypes. They are case-insitive. " + "Separate multiple by tags by commas."] + tags = metaprot.tags + + if tags: + text.append("The current tags are:\n|y{tags}|n".format(tags)) + else: + text.append("No tags are currently set.") + text = "\n".join(text) + options = ({"desc": "forward (locks)", + "goto": "node_locks"}, + {"desc": "back (desc)", + "goto": "node_desc"}, + {"key": "_default", + "desc": "enter tags separated by commas", + "goto": _node_check_tags}) + return text, options + + +def _node_check_locks(caller, lockstring): + # TODO - have a way to validate lock string here + _set_menu_metaprot(caller, 'locks', lockstring) + caller.msg("Set lockstring '{lockstring}'.".format(lockstring=lockstring)) + return "node_prototype_index" + + +def node_locks(caller): + metaprot = _get_menu_metaprot(caller) + text = ["Set |ylocks|n on the prototype. There are two valid lock types: " + "'edit' (who can edit the prototype) and 'use' (who can apply the prototype)\n" + "(If you are unsure, leave as default.)"] + locks = metaprot.locks + if locks: + text.append("Current lock is |y'{lockstring}'|n".format(lockstring=locks)) + else: + text.append("Lock unset - if not changed the default lockstring will be set as\n" + " |y'use:all(); edit:id({dbref}) or perm(Admin)'|n".format(dbref=caller.id)) + text = "\n".join(text) + options = ({"desc": "forward (prototype)", + "goto": "node_prototype_index"}, + {"desc": "back (tags)", + "goto": "node_tags"}, + {"key": "_default", + "desc": "enter lockstring", + "goto": _node_check_locks}) + + return text, options + + +def node_prototype_index(caller): + pass + + +def start_olc(caller, session=None, metaproto=None): + """ + Start menu-driven olc system for prototypes. + + Args: + caller (Object or Account): The entity starting the menu. + session (Session, optional): The individual session to get data. + metaproto (MetaProto, optional): Given when editing an existing + prototype rather than creating a new one. + + """ + + menudata = {"node_index": node_index, + "node_key": node_key, + "node_desc": node_desc, + "node_tags": node_tags, + "node_locks": node_locks, + "node_prototype_index": node_prototype_index} + EvMenu(caller, menudata, startnode='node_index', session=session, olc_metaproto=metaproto) + + # Testing if __name__ == "__main__": From 36e294a9d4ee3d4cefc028abf60662a2042a97e9 Mon Sep 17 00:00:00 2001 From: friarzen Date: Mon, 19 Mar 2018 00:59:28 +0000 Subject: [PATCH 064/208] Fix append scrolling -- needs more testing --- .../web/webclient/static/webclient/js/webclient_gui.js | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/evennia/web/webclient/static/webclient/js/webclient_gui.js b/evennia/web/webclient/static/webclient/js/webclient_gui.js index d07239039f..4189e026fb 100644 --- a/evennia/web/webclient/static/webclient/js/webclient_gui.js +++ b/evennia/web/webclient/static/webclient/js/webclient_gui.js @@ -257,14 +257,15 @@ function onText(args, kwargs) { if( SplitHandler ) { for ( var key in SplitHandler.split_panes) { var pane = SplitHandler.split_panes[key]; - console.log(pane); // is this message type mapped to this pane? if ( (pane['types'].length > 0) && pane['types'].includes(msgtype) ) { // yes, so append/replace this pane's inner div with this message if ( pane['update_method'] == 'replace' ) { $('#'+key).html(args[0]) } else { - $('#'+key).append(args[0]).animate({ scrollTop: document.getElementById("#"+key).scrollHeight }, 0); + $('#'+key).append(args[0]); + var scrollHeight = $('#'+key).parent().prop("scrollHeight"); + $('#'+key).parent().animate({ scrollTop: scrollHeight }, 0); } // record sending this message to a pane, no need to update the default div use_default_pane = false; @@ -279,9 +280,8 @@ function onText(args, kwargs) { var mwin = $("#messagewindow"); var cls = kwargs == null ? 'out' : kwargs['cls']; mwin.append("
" + args[0] + "
"); - mwin.animate({ - scrollTop: document.getElementById("messagewindow").scrollHeight - }, 0); + var scrollHeight = mwin.parent().parent().prop("scrollHeight"); + mwin.parent().parent().animate({ scrollTop: scrollHeight }, 0); onNewLine(args[0], null); } From acc78186e34bfbf0f21aa7ed2d65803ff319673c Mon Sep 17 00:00:00 2001 From: Nicholas Matlaga Date: Mon, 19 Mar 2018 11:20:08 -0400 Subject: [PATCH 065/208] fix webclient/base.html (500 error) --- .../webclient/templates/webclient/base.html | 100 +++++++----------- 1 file changed, 37 insertions(+), 63 deletions(-) diff --git a/evennia/web/webclient/templates/webclient/base.html b/evennia/web/webclient/templates/webclient/base.html index 3852b45273..a5c65fad2c 100644 --- a/evennia/web/webclient/templates/webclient/base.html +++ b/evennia/web/webclient/templates/webclient/base.html @@ -13,16 +13,18 @@ JQuery available. - - + + + + {% block jquery_import %} - + {% endblock %} + + + - + + + + + + + + {% block guilib_import %} + + {% endblock %} + - - + + + + {% block scripts %} + {% endblock %} @@ -74,6 +101,10 @@ JQuery available. web browser supporting javascript.

This error could also be due to not being able to access the online jQuery javascript library.

+ + @@ -81,62 +112,5 @@ JQuery available. {% block client %} {% endblock %} - - - - {% block jquery_import %} - - {% endblock %} - - - - - - - - - - - - - - - - - - {% block guilib_import %} - - {% endblock %} - - - - - {% block scripts %} - {% endblock %} From 5410640de3888d63ae36da8b2c7d49f5d64fd7ee Mon Sep 17 00:00:00 2001 From: Griatch Date: Mon, 19 Mar 2018 20:27:55 +0100 Subject: [PATCH 066/208] [fix] Add better error reporting from EvMenu --- evennia/utils/evmenu.py | 6 +++++- evennia/utils/spawner.py | 5 +++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/evennia/utils/evmenu.py b/evennia/utils/evmenu.py index 9509bbd884..a6a77a871f 100644 --- a/evennia/utils/evmenu.py +++ b/evennia/utils/evmenu.py @@ -182,7 +182,8 @@ _CMD_NOINPUT = cmdhandler.CMD_NOINPUT # i18n from django.utils.translation import ugettext as _ -_ERR_NOT_IMPLEMENTED = _("Menu node '{nodename}' is not implemented. Make another choice.") +_ERR_NOT_IMPLEMENTED = _("Menu node '{nodename}' is either not implemented or " + "caused an error. Make another choice.") _ERR_GENERAL = _("Error in menu node '{nodename}'.") _ERR_NO_OPTION_DESC = _("No description.") _HELP_FULL = _("Commands: , help, quit") @@ -573,6 +574,7 @@ class EvMenu(object): except EvMenuError: errmsg = _ERR_GENERAL.format(nodename=callback) self.caller.msg(errmsg, self._session) + logger.log_trace() raise return ret @@ -606,9 +608,11 @@ class EvMenu(object): nodetext, options = ret, None except KeyError: self.caller.msg(_ERR_NOT_IMPLEMENTED.format(nodename=nodename), session=self._session) + logger.log_trace() raise EvMenuError except Exception: self.caller.msg(_ERR_GENERAL.format(nodename=nodename), session=self._session) + logger.log_trace() raise # store options to make them easier to test diff --git a/evennia/utils/spawner.py b/evennia/utils/spawner.py index e4d403157e..33fbb91346 100644 --- a/evennia/utils/spawner.py +++ b/evennia/utils/spawner.py @@ -754,12 +754,13 @@ def _node_check_desc(caller, desc): def node_desc(caller): + metaprot = _get_menu_metaprot(caller) text = ["|cDescribe|n briefly the prototype for viewing in listings."] desc = metaprot.desc if desc: - text.append("The current desc is:\n\"|y{desc}|n\"".format(desc)) + text.append("The current desc is:\n\"|y{desc}|n\"".format(desc=desc)) else: text.append("Description is currently unset.") text = "\n".join(text) @@ -788,7 +789,7 @@ def node_tags(caller): tags = metaprot.tags if tags: - text.append("The current tags are:\n|y{tags}|n".format(tags)) + text.append("The current tags are:\n|y{tags}|n".format(tags=tags)) else: text.append("No tags are currently set.") text = "\n".join(text) From b4a2713333ac3f98b1fb6b6c7e0e8262c219f5b3 Mon Sep 17 00:00:00 2001 From: Griatch Date: Mon, 19 Mar 2018 20:59:32 +0100 Subject: [PATCH 067/208] Separate prototype meta-properties from prototype properties in menu --- evennia/utils/spawner.py | 73 +++++++++++++++++++++------------------- 1 file changed, 39 insertions(+), 34 deletions(-) diff --git a/evennia/utils/spawner.py b/evennia/utils/spawner.py index 33fbb91346..8ce91fc970 100644 --- a/evennia/utils/spawner.py +++ b/evennia/utils/spawner.py @@ -678,7 +678,7 @@ def _set_menu_metaprot(caller, field, value): caller.ndb._menutree.olc_metaprot = build_metaproto(**kwargs) -def node_index(caller): +def node_meta_index(caller): metaprot = _get_menu_metaprot(caller) key = "|g{}|n".format( crop(metaprot.key, _MENU_CROP_WIDTH)) if metaprot.key else "|rundefined, required|n" @@ -693,11 +693,11 @@ def node_index(caller): text = ("|c --- Prototype wizard --- |n\n" "(make choice; q to abort, h for help)") options = ( - {"desc": "Key ({})".format(key), "goto": "node_key"}, - {"desc": "Description ({})".format(desc), "goto": "node_desc"}, - {"desc": "Tags ({})".format(tags), "goto": "node_tags"}, - {"desc": "Locks ({})".format(locks), "goto": "node_locks"}, - {"desc": "Prototype ({})".format(prot), "goto": "node_prototype_index"}) + {"desc": "Key ({})".format(key), "goto": "node_meta_key"}, + {"desc": "Description ({})".format(desc), "goto": "node_meta_desc"}, + {"desc": "Tags ({})".format(tags), "goto": "node_meta_tags"}, + {"desc": "Locks ({})".format(locks), "goto": "node_meta_locks"}, + {"desc": "Prototype[menu] ({})".format(prot), "goto": "node_prototype_index"}) return text, options @@ -708,38 +708,39 @@ def _node_check_key(caller, key): if old_metaprot: # we are starting a new prototype that matches an existing if not caller.locks.check_lockstring(caller, old_metaprot.locks, access_type='edit'): - # return to the node_key to try another key + # return to the node_meta_key to try another key caller.msg("Prototype '{key}' already exists and you don't " "have permission to edit it.".format(key=key)) - return "node_key" + return "node_meta_key" elif olc_new: # we are selecting an existing prototype to edit. Reset to index. del caller.ndb._menutree.olc_new caller.ndb._menutree.olc_metaprot = old_metaprot caller.msg("Prototype already exists. Reloading.") - return "node_index" + return "node_meta_index" # continue on _set_menu_metaprot(caller, 'key', key) caller.msg("Key '{key}' was set.".format(key=key)) - return "node_desc" + return "node_meta_desc" -def node_key(caller): +def node_meta_key(caller): metaprot = _get_menu_metaprot(caller) - text = ["The |ckey|n must be unique and is used to find and use " - "the prototype to spawn new entities. It is not case sensitive."] + text = ["The prototype name, or |ckey|n, uniquely identifies the prototype. " + "It is used to find and use the prototype to spawn new entities. " + "It is not case sensitive."] old_key = metaprot.key if old_key: text.append("Current key is '|y{key}|n'".format(key=old_key)) else: text.append("The key is currently unset.") - text.append("Enter text or make choice (q for quit, h for help)") + text.append("Enter text or make a choice (q for quit, h for help)") text = "\n".join(text) options = ({"desc": "forward (desc)", - "goto": "node_desc"}, + "goto": "node_meta_desc"}, {"desc": "back (index)", - "goto": "node_index"}, + "goto": "node_meta_index"}, {"key": "_default", "desc": "enter a key", "goto": _node_check_key}) @@ -750,24 +751,24 @@ def _node_check_desc(caller, desc): desc = desc.strip() _set_menu_metaprot(caller, 'desc', desc) caller.msg("Description was set to '{desc}'.".format(desc=desc)) - return "node_tags" + return "node_meta_tags" -def node_desc(caller): +def node_meta_desc(caller): metaprot = _get_menu_metaprot(caller) text = ["|cDescribe|n briefly the prototype for viewing in listings."] desc = metaprot.desc if desc: - text.append("The current desc is:\n\"|y{desc}|n\"".format(desc=desc)) + text.append("The current meta desc is:\n\"|y{desc}|n\"".format(desc=desc)) else: text.append("Description is currently unset.") text = "\n".join(text) options = ({"desc": "forward (tags)", - "goto": "node_tags"}, + "goto": "node_meta_tags"}, {"desc": "back (key)", - "goto": "node_key"}, + "goto": "node_meta_key"}, {"key": "_default", "desc": "enter a description", "goto": _node_check_desc}) @@ -779,12 +780,12 @@ def _node_check_tags(caller, tags): tags = [part.strip().lower() for part in tags.split(",")] _set_menu_metaprot(caller, 'tags', tags) caller.msg("Tags {tags} were set".format(tags=tags)) - return "node_locks" + return "node_meta_locks" -def node_tags(caller): +def node_meta_tags(caller): metaprot = _get_menu_metaprot(caller) - text = ["|cTags|n can be used to find prototypes. They are case-insitive. " + text = ["|cTags|n can be used to classify and find prototypes. Tags are case-insensitive. " "Separate multiple by tags by commas."] tags = metaprot.tags @@ -794,9 +795,9 @@ def node_tags(caller): text.append("No tags are currently set.") text = "\n".join(text) options = ({"desc": "forward (locks)", - "goto": "node_locks"}, + "goto": "node_meta_locks"}, {"desc": "back (desc)", - "goto": "node_desc"}, + "goto": "node_meta_desc"}, {"key": "_default", "desc": "enter tags separated by commas", "goto": _node_check_tags}) @@ -810,7 +811,7 @@ def _node_check_locks(caller, lockstring): return "node_prototype_index" -def node_locks(caller): +def node_meta_locks(caller): metaprot = _get_menu_metaprot(caller) text = ["Set |ylocks|n on the prototype. There are two valid lock types: " "'edit' (who can edit the prototype) and 'use' (who can apply the prototype)\n" @@ -825,7 +826,7 @@ def node_locks(caller): options = ({"desc": "forward (prototype)", "goto": "node_prototype_index"}, {"desc": "back (tags)", - "goto": "node_tags"}, + "goto": "node_meta_tags"}, {"key": "_default", "desc": "enter lockstring", "goto": _node_check_locks}) @@ -834,6 +835,10 @@ def node_locks(caller): def node_prototype_index(caller): + metaprot = _get_menu_metaprot(caller) + text = [" |c--- Prototype menu --- |n" + ] + pass @@ -849,13 +854,13 @@ def start_olc(caller, session=None, metaproto=None): """ - menudata = {"node_index": node_index, - "node_key": node_key, - "node_desc": node_desc, - "node_tags": node_tags, - "node_locks": node_locks, + menudata = {"node_meta_index": node_meta_index, + "node_meta_key": node_meta_key, + "node_meta_desc": node_meta_desc, + "node_meta_tags": node_meta_tags, + "node_meta_locks": node_meta_locks, "node_prototype_index": node_prototype_index} - EvMenu(caller, menudata, startnode='node_index', session=session, olc_metaproto=metaproto) + EvMenu(caller, menudata, startnode='node_meta_index', session=session, olc_metaproto=metaproto) # Testing From 36c89799cddd39905d82b54fce926dc6d44e87be Mon Sep 17 00:00:00 2001 From: friarzen Date: Wed, 21 Mar 2018 18:35:48 +0000 Subject: [PATCH 068/208] Add user selected names to each new pane and some CSS --- .../static/webclient/css/webclient.css | 17 +++++ .../static/webclient/js/splithandler.js | 44 +++++------ .../static/webclient/js/webclient_gui.js | 73 ++++++++++++------- .../templates/webclient/webclient.html | 6 +- 4 files changed, 86 insertions(+), 54 deletions(-) diff --git a/evennia/web/webclient/static/webclient/css/webclient.css b/evennia/web/webclient/static/webclient/css/webclient.css index 94344386a1..1e9adb283b 100644 --- a/evennia/web/webclient/static/webclient/css/webclient.css +++ b/evennia/web/webclient/static/webclient/css/webclient.css @@ -147,6 +147,19 @@ div {margin:0px;} cursor: pointer; } +.button { + width: fit-content; + padding: 1em; + color: black; + border: 1px solid black; + background-color: darkgray; + margin: 0 auto; +} + +.splitbutton:hover { + cursor: pointer; +} + #optionsbutton { width: 2rem; font-size: 2rem; @@ -256,6 +269,10 @@ div {margin:0px;} overflow-x: hidden; } +.split-sub { + padding: .5rem; +} + .content { border: 1px solid #C0C0C0; box-shadow: inset 0 1px 2px #e4e4e4; diff --git a/evennia/web/webclient/static/webclient/js/splithandler.js b/evennia/web/webclient/static/webclient/js/splithandler.js index 7ba8e45e17..aa6ea4364a 100644 --- a/evennia/web/webclient/static/webclient/js/splithandler.js +++ b/evennia/web/webclient/static/webclient/js/splithandler.js @@ -1,50 +1,50 @@ // Use split.js to create a basic ui var SplitHandler = (function () { - var num_splits = 0; var split_panes = {}; var set_pane_types = function(splitpane, types) { split_panes[splitpane]['types'] = types; } - var dynamic_split = function(splitpane, direction, update_method1, update_method2) { - var first = ++num_splits; - var second = ++num_splits; - var first_div = $( '
' ) - var first_sub = $( '
' ) - var second_div = $( '
' ) - var second_sub = $( '
' ) + var dynamic_split = function(splitpane, direction, pane_name1, pane_name2, update_method1, update_method2, sizes) { + // find the sub-div of the pane we are being asked to split + splitpanesub = splitpane + '-sub'; - // check to see if this pane contains the primary message window. - contents = $('#'+splitpane).contents(); + // create the new div stack to replace the sub-div with. + var first_div = $( '
' ) + var first_sub = $( '
' ) + var second_div = $( '
' ) + var second_sub = $( '
' ) + + // check to see if this sub-pane contains anything + contents = $('#'+splitpanesub).contents(); if( contents ) { - // it does, so move it to the first new div (TODO -- selectable between first/second?) + // it does, so move it to the first new div-sub (TODO -- selectable between first/second?) contents.appendTo(first_sub); } - first_div.append( first_sub ); second_div.append( second_sub ); - // update the split_panes array to remove this split + // update the split_panes array to remove this pane name delete( split_panes[splitpane] ); // now vaporize the current split_N-sub placeholder and create two new panes. - $('#'+splitpane).parent().append(first_div); - $('#'+splitpane).parent().append(second_div); - $('#'+splitpane).remove(); + $('#'+splitpane).append(first_div); + $('#'+splitpane).append(second_div); + $('#'+splitpane+'-sub').remove(); // And split - Split(['#split_'+first,'#split_'+second], { + Split(['#'+pane_name1,'#'+pane_name2], { direction: direction, - sizes: [50,50], + sizes: sizes, gutterSize: 4, minSize: [50,50], }); - // store our new splits for future splits/uses by the main UI. - split_panes['split_'+first +'-sub'] = { 'types': [], 'update_method': update_method1 }; - split_panes['split_'+second+'-sub'] = { 'types': [], 'update_method': update_method2 }; + // store our new split sub-divs for future splits/uses by the main UI. + split_panes[pane_name1] = { 'types': [], 'update_method': update_method1 }; + split_panes[pane_name2] = { 'types': [], 'update_method': update_method2 }; } @@ -63,7 +63,7 @@ var SplitHandler = (function () { minSize: [50,50], }); - split_panes['main-sub'] = {'types': [], 'update_method': 'append'}; + split_panes['main'] = { 'types': [], 'update_method': 'append' }; var input_render = Mustache.render(input_template); $('[data-role-input]').html(input_render); diff --git a/evennia/web/webclient/static/webclient/js/webclient_gui.js b/evennia/web/webclient/static/webclient/js/webclient_gui.js index 4189e026fb..7a7c218921 100644 --- a/evennia/web/webclient/static/webclient/js/webclient_gui.js +++ b/evennia/web/webclient/static/webclient/js/webclient_gui.js @@ -15,6 +15,7 @@ (function () { "use strict" +var num_splits = 0; var options = {}; @@ -167,7 +168,7 @@ function onKeydown (event) { return; } - inputfield.focus(); + //inputfield.focus(); if (code === 13) { // Enter key sends text doSendText(); @@ -245,31 +246,23 @@ function onText(args, kwargs) { known_types.push(msgtype); } - if ( msgtype == 'help' ) { - if (("helppopup" in options) && (options["helppopup"])) { - openPopup("#helpdialog", args[0]); - return; - } - // fall through to the default output - - } else { - // pass this message to each pane that has this msgtype mapped - if( SplitHandler ) { - for ( var key in SplitHandler.split_panes) { - var pane = SplitHandler.split_panes[key]; - // is this message type mapped to this pane? - if ( (pane['types'].length > 0) && pane['types'].includes(msgtype) ) { - // yes, so append/replace this pane's inner div with this message - if ( pane['update_method'] == 'replace' ) { - $('#'+key).html(args[0]) - } else { - $('#'+key).append(args[0]); - var scrollHeight = $('#'+key).parent().prop("scrollHeight"); - $('#'+key).parent().animate({ scrollTop: scrollHeight }, 0); - } - // record sending this message to a pane, no need to update the default div - use_default_pane = false; + // pass this message to each pane that has this msgtype mapped + if( SplitHandler ) { + for ( var key in SplitHandler.split_panes) { + var pane = SplitHandler.split_panes[key]; + // is this message type mapped to this pane? + if ( (pane['types'].length > 0) && pane['types'].includes(msgtype) ) { + // yes, so append/replace this pane's inner div with this message + var text_div = $('#'+key+'-sub'); + if ( pane['update_method'] == 'replace' ) { + text_div.html(args[0]) + } else { + text_div.append(args[0]); + var scrollHeight = text_div.parent().prop("scrollHeight"); + text_div.parent().animate({ scrollTop: scrollHeight }, 0); } + // record sending this message to a pane, no need to update the default div + use_default_pane = false; } } } @@ -441,19 +434,39 @@ function doStartDragDialog(event) { $(document).bind("mouseup", undrag); } - function onSplitDialogClose() { var pane = $("input[name=pane]:checked").attr("value"); var direction = $("input[name=direction]:checked").attr("value"); + var new_pane1 = $("input[name=new_pane1]").val(); + var new_pane2 = $("input[name=new_pane2]").val(); var flow1 = $("input[name=flow1]:checked").attr("value"); var flow2 = $("input[name=flow2]:checked").attr("value"); - SplitHandler.dynamic_split( pane, direction, flow1, flow2 ); + if( new_pane1 == "" ) { + new_pane1 = 'pane_'+num_splits; + num_splits++; + } + + if( new_pane2 == "" ) { + new_pane2 = 'pane_'+num_splits; + num_splits++; + } + + if( document.getElementById(new_pane1) ) { + alert('An element: "' + new_pane1 + '" already exists'); + return; + } + + if( document.getElementById(new_pane2) ) { + alert('An element: "' + new_pane2 + '" already exists'); + return; + } + + SplitHandler.dynamic_split( pane, direction, new_pane1, new_pane2, flow1, flow2, [50,50] ); closePopup("#splitdialog"); } - function onSplitDialog() { var dialog = $("#splitdialogcontent"); dialog.empty(); @@ -467,6 +480,10 @@ function onSplitDialog() { dialog.append(''+ pane +'
'); } + dialog.append("

New Pane Names

"); + dialog.append(''); + dialog.append(''); + dialog.append("

New First Pane Flow

"); dialog.append('append
'); dialog.append('replace
'); diff --git a/evennia/web/webclient/templates/webclient/webclient.html b/evennia/web/webclient/templates/webclient/webclient.html index 2b138cb8bd..b750257048 100644 --- a/evennia/web/webclient/templates/webclient/webclient.html +++ b/evennia/web/webclient/templates/webclient/webclient.html @@ -16,14 +16,12 @@
-
+
- -
-
+
From 8bde43a3e34e99b58d980fda2ecd17dc2847dfcc Mon Sep 17 00:00:00 2001 From: friarzen Date: Thu, 22 Mar 2018 00:52:19 +0000 Subject: [PATCH 069/208] adjust css to match existing toolbar and toggle split/pane popup --- .../static/webclient/css/webclient.css | 22 +++++++++++++------ .../static/webclient/js/webclient_gui.js | 4 ++-- 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/evennia/web/webclient/static/webclient/css/webclient.css b/evennia/web/webclient/static/webclient/css/webclient.css index 1e9adb283b..75dd91ce2a 100644 --- a/evennia/web/webclient/static/webclient/css/webclient.css +++ b/evennia/web/webclient/static/webclient/css/webclient.css @@ -129,20 +129,28 @@ div {margin:0px;} max-height: 3rem; } -.splitbutton { - position: absolute; - right: 1%; - top: 1%; - z-index: 1; +#splitbutton { width: 2rem; - height: 2rem; font-size: 2rem; color: #a6a6a6; background-color: transparent; border: 0px; } -.splitbutton:hover { +#splitbutton:hover { + color: white; + cursor: pointer; +} + +#panebutton { + width: 2rem; + font-size: 2rem; + color: #a6a6a6; + background-color: transparent; + border: 0px; +} + +#panebutton:hover { color: white; cursor: pointer; } diff --git a/evennia/web/webclient/static/webclient/js/webclient_gui.js b/evennia/web/webclient/static/webclient/js/webclient_gui.js index 7a7c218921..b975ae7044 100644 --- a/evennia/web/webclient/static/webclient/js/webclient_gui.js +++ b/evennia/web/webclient/static/webclient/js/webclient_gui.js @@ -496,7 +496,7 @@ function onSplitDialog() { $("#splitclose").bind("click", onSplitDialogClose); - openPopup("#splitdialog"); + togglePopup("#splitdialog"); } function onPaneControlDialogClose() { @@ -530,7 +530,7 @@ function onPaneControlDialog() { $("#paneclose").bind("click", onPaneControlDialogClose); - openPopup("#splitdialog"); + togglePopup("#splitdialog"); } // From 535ac26c222a415866465669770f979eae0e2811 Mon Sep 17 00:00:00 2001 From: Griatch Date: Sat, 24 Mar 2018 17:28:56 +0100 Subject: [PATCH 070/208] Refactor spawner menu --- evennia/utils/evmenu.py | 17 ++- evennia/utils/spawner.py | 245 +++++++++++++++++++++++++-------------- 2 files changed, 171 insertions(+), 91 deletions(-) diff --git a/evennia/utils/evmenu.py b/evennia/utils/evmenu.py index a6a77a871f..94c1467419 100644 --- a/evennia/utils/evmenu.py +++ b/evennia/utils/evmenu.py @@ -43,13 +43,18 @@ command definition too) with function definitions: def node_with_other_name(caller, input_string): # code return text, options + + def another_node(caller, input_string, **kwargs): + # code + return text, options ``` Where caller is the object using the menu and input_string is the command entered by the user on the *previous* node (the command entered to get to this node). The node function code will only be executed once per node-visit and the system will accept nodes with -both one or two arguments interchangeably. +both one or two arguments interchangeably. It also accepts nodes +that takes **kwargs. The menu tree itself is available on the caller as `caller.ndb._menutree`. This makes it a convenient place to store @@ -82,12 +87,14 @@ menu is immediately exited and the default "look" command is called. the callable. Those kwargs will also be passed into the next node if possible. Such a callable should return either a str or a (str, dict), where the string is the name of the next node to go to and the dict is the new, - (possibly modified) kwarg to pass into the next node. + (possibly modified) kwarg to pass into the next node. If the callable returns + None or the empty string, the current node will be revisited. - `exec` (str, callable or tuple, optional): This takes the same input as `goto` above and runs before it. If given a node name, the node will be executed but will not be considered the next node. If node/callback returns str or (str, dict), these will replace the `goto` step (`goto` callbacks will not fire), with the string being the next node name and the optional dict acting as the kwargs-input for the next node. + If an exec callable returns the empty string (only), the current node is re-run. If key is not given, the option will automatically be identified by its number 1..N. @@ -669,6 +676,9 @@ class EvMenu(object): if isinstance(ret, basestring): # only return a value if a string (a goto target), ignore all other returns + if not ret: + # an empty string - rerun the same node + return self.nodename return ret, kwargs return None @@ -718,6 +728,9 @@ class EvMenu(object): raise EvMenuError( "{}: goto callable must return str or (str, dict)".format(inp_nodename)) nodename, kwargs = nodename[:2] + if not nodename: + # no nodename return. Re-run current node + nodename = self.nodename try: # execute the found node, make use of the returns. nodetext, options = self._execute_node(nodename, raw_string, **kwargs) diff --git a/evennia/utils/spawner.py b/evennia/utils/spawner.py index 8ce91fc970..2f31da0f49 100644 --- a/evennia/utils/spawner.py +++ b/evennia/utils/spawner.py @@ -671,6 +671,10 @@ def _get_menu_metaprot(caller): return metaproto +def _is_new_prototype(caller): + return hasattr(caller.ndb._menutree, "olc_new") + + def _set_menu_metaprot(caller, field, value): metaprot = _get_menu_metaprot(caller) kwargs = dict(metaprot.__dict__) @@ -678,30 +682,121 @@ def _set_menu_metaprot(caller, field, value): caller.ndb._menutree.olc_metaprot = build_metaproto(**kwargs) -def node_meta_index(caller): - metaprot = _get_menu_metaprot(caller) - key = "|g{}|n".format( - crop(metaprot.key, _MENU_CROP_WIDTH)) if metaprot.key else "|rundefined, required|n" - desc = "|g{}|n".format( - crop(metaprot.desc, _MENU_CROP_WIDTH)) if metaprot.desc else "''" - tags = "|g{}|n".format( - crop(", ".join(metaprot.tags), _MENU_CROP_WIDTH)) if metaprot.tags else [] - locks = "|g{}|n".format( - crop(", ".join(metaprot.locks), _MENU_CROP_WIDTH)) if metaprot.tags else [] - prot = "|gdefined|n" if metaprot.prototype else "|rundefined, required|n" +def _format_property(key, required=False, metaprot=None, prototype=None): + key = key.lower() + if metaprot is not None: + prop = getattr(metaprot, key) or '' + elif prototype is not None: + prop = prototype.get(key, '') + + out = prop + if callable(prop): + if hasattr(prop, '__name__'): + out = "<{}>".format(prop.__name__) + else: + out = repr(prop) + if is_iter(prop): + out = ", ".join(str(pr) for pr in prop) + if not out and required: + out = "|rrequired" + return " ({}|n)".format(crop(out, _MENU_CROP_WIDTH)) + + +def _set_property(caller, raw_string, **kwargs): + """ + Update a property. To be called by the 'goto' option variable. + + Args: + caller (Object, Account): The user of the wizard. + raw_string (str): Input from user on given node - the new value to set. + Kwargs: + prop (str): Property name to edit with `raw_string`. + processor (callable): Converts `raw_string` to a form suitable for saving. + next_node (str): Where to redirect to after this has run. + Returns: + next_node (str): Next node to go to. + + """ + prop = kwargs.get("prop", "meta_key") + processor = kwargs.get("processor", None) + next_node = kwargs.get("next_node", "node_index") + + propname_low = prop.strip().lower() + meta = propname_low.startswith("meta_") + if meta: + propname_low = propname_low[5:] + raw_string = raw_string.strip() + + if callable(processor): + try: + value = processor(raw_string) + except Exception as err: + caller.msg("Could not set {prop} to {value} ({err})".format( + prop=prop.replace("_", "-").capitalize(), value=raw_string, err=str(err))) + # this means we'll re-run the current node. + return None + else: + value = raw_string + + if meta: + _set_menu_metaprot(caller, propname_low, value) + else: + metaprot = _get_menu_metaprot(caller) + prototype = metaprot.prototype + prototype[propname_low] = value + _set_menu_metaprot(caller, "prototype", prototype) + + caller.msg("Set {prop} to {value}.".format( + prop=prop.replace("_", "-").capitalize(), value=str(value))) + + return next_node + + +def _wizard_options(prev_node, next_node): + options = [{"desc": "forward ({})".format(next_node.replace("_", "-")), + "goto": "node_{}".format(next_node)}, + {"desc": "back ({})".format(prev_node.replace("_", "-")), + "goto": "node_{}".format(prev_node)}] + if "index" not in (prev_node, next_node): + options.append({"desc": "index", + "goto": "node_index"}) + return options + + +def node_index(caller): + metaprot = _get_menu_metaprot(caller) + prototype = metaprot.prototype + + text = ("|c --- Prototype wizard --- |n\n\n" + "Define properties of the prototype. All prototype values can be over-ridden at " + "the time of spawning an instance of the prototype, but some are required.\n\n" + "'Meta'-properties are not used in the prototype itself but are used to organize and " + "list prototypes. The 'Meta-Key' uniquely identifies the prototype and allows you to " + "edit an existing prototype or save a new one for use by you or others later.\n\n" + "(make choice; q to abort. If unsure, start from 1.)") + + options = [] + # The meta-key goes first + options.append( + {"desc": "|WMeta-Key|n|n{}".format(_format_property("Key", True, metaprot, None)), + "goto": "node_meta_key"}) + for key in ('Prototype', 'Typeclass', 'Key', 'Aliases', 'Home', 'Destination', + 'Permissions', 'Locks', 'Location', 'Tags', 'Attrs'): + req = False + if key in ("Prototype", "Typeclass"): + req = "prototype" not in prototype and "typeclass" not in prototype + options.append( + {"desc": "|w{}|n{}".format(key, _format_property(key, req, None, prototype)), + "goto": "node_{}".format(key.lower())}) + for key in ('Desc', 'Tags', 'Locks'): + options.append( + {"desc": "|WMeta-{}|n|n{}".format(key, _format_property(key, req, metaprot, None)), + "goto": "node_meta_{}".format(key.lower())}) - text = ("|c --- Prototype wizard --- |n\n" - "(make choice; q to abort, h for help)") - options = ( - {"desc": "Key ({})".format(key), "goto": "node_meta_key"}, - {"desc": "Description ({})".format(desc), "goto": "node_meta_desc"}, - {"desc": "Tags ({})".format(tags), "goto": "node_meta_tags"}, - {"desc": "Locks ({})".format(locks), "goto": "node_meta_locks"}, - {"desc": "Prototype[menu] ({})".format(prot), "goto": "node_prototype_index"}) return text, options -def _node_check_key(caller, key): +def _check_meta_key(caller, key): old_metaprot = search_prototype(key) olc_new = caller.ndb._menutree.olc_new key = key.strip().lower() @@ -719,15 +814,12 @@ def _node_check_key(caller, key): caller.msg("Prototype already exists. Reloading.") return "node_meta_index" - # continue on - _set_menu_metaprot(caller, 'key', key) - caller.msg("Key '{key}' was set.".format(key=key)) - return "node_meta_desc" + return _set_property(caller, key, prop='meta_key', next_node="node_meta_desc") def node_meta_key(caller): metaprot = _get_menu_metaprot(caller) - text = ["The prototype name, or |ckey|n, uniquely identifies the prototype. " + text = ["The prototype name, or |cmeta-key|n, uniquely identifies the prototype. " "It is used to find and use the prototype to spawn new entities. " "It is not case sensitive."] old_key = metaprot.key @@ -735,25 +827,14 @@ def node_meta_key(caller): text.append("Current key is '|y{key}|n'".format(key=old_key)) else: text.append("The key is currently unset.") - text.append("Enter text or make a choice (q for quit, h for help)") - text = "\n".join(text) - options = ({"desc": "forward (desc)", - "goto": "node_meta_desc"}, - {"desc": "back (index)", - "goto": "node_meta_index"}, - {"key": "_default", - "desc": "enter a key", - "goto": _node_check_key}) + text.append("Enter text or make a choice (q for quit)") + text = "\n\n".join(text) + options = _wizard_options("index", "meta_desc") + options.append({"key": "_default", + "goto": _check_meta_key}) return text, options -def _node_check_desc(caller, desc): - desc = desc.strip() - _set_menu_metaprot(caller, 'desc', desc) - caller.msg("Description was set to '{desc}'.".format(desc=desc)) - return "node_meta_tags" - - def node_meta_desc(caller): metaprot = _get_menu_metaprot(caller) @@ -764,25 +845,14 @@ def node_meta_desc(caller): text.append("The current meta desc is:\n\"|y{desc}|n\"".format(desc=desc)) else: text.append("Description is currently unset.") - text = "\n".join(text) - options = ({"desc": "forward (tags)", - "goto": "node_meta_tags"}, - {"desc": "back (key)", - "goto": "node_meta_key"}, - {"key": "_default", - "desc": "enter a description", - "goto": _node_check_desc}) - + text = "\n\n".join(text) + options = _wizard_options("meta_key", "meta_tags") + options.append({"key": "_default", + "goto": (_set_property, + dict(prop='meta_desc', next_node="node_meta_tags"))}) return text, options -def _node_check_tags(caller, tags): - tags = [part.strip().lower() for part in tags.split(",")] - _set_menu_metaprot(caller, 'tags', tags) - caller.msg("Tags {tags} were set".format(tags=tags)) - return "node_meta_locks" - - def node_meta_tags(caller): metaprot = _get_menu_metaprot(caller) text = ["|cTags|n can be used to classify and find prototypes. Tags are case-insensitive. " @@ -793,24 +863,16 @@ def node_meta_tags(caller): text.append("The current tags are:\n|y{tags}|n".format(tags=tags)) else: text.append("No tags are currently set.") - text = "\n".join(text) - options = ({"desc": "forward (locks)", - "goto": "node_meta_locks"}, - {"desc": "back (desc)", - "goto": "node_meta_desc"}, - {"key": "_default", - "desc": "enter tags separated by commas", - "goto": _node_check_tags}) + text = "\n\n".join(text) + options = _wizard_options("meta_desc", "meta_locks") + options.append({"key": "_default", + "goto": (_set_property, + dict(prop="meta_tags", + processor=lambda s: [str(part.strip()) for part in s.split(",")], + next_node="node_meta_locks"))}) return text, options -def _node_check_locks(caller, lockstring): - # TODO - have a way to validate lock string here - _set_menu_metaprot(caller, 'locks', lockstring) - caller.msg("Set lockstring '{lockstring}'.".format(lockstring=lockstring)) - return "node_prototype_index" - - def node_meta_locks(caller): metaprot = _get_menu_metaprot(caller) text = ["Set |ylocks|n on the prototype. There are two valid lock types: " @@ -822,24 +884,30 @@ def node_meta_locks(caller): else: text.append("Lock unset - if not changed the default lockstring will be set as\n" " |y'use:all(); edit:id({dbref}) or perm(Admin)'|n".format(dbref=caller.id)) - text = "\n".join(text) - options = ({"desc": "forward (prototype)", - "goto": "node_prototype_index"}, - {"desc": "back (tags)", - "goto": "node_meta_tags"}, - {"key": "_default", - "desc": "enter lockstring", - "goto": _node_check_locks}) - + text = "\n\n".join(text) + options = _wizard_options("meta_tags", "prototype") + options.append({"key": "_default", + "desc": "enter lockstring", + "goto": (_set_property, + dict(prop="meta_locks", + next_node="node_key"))}) return text, options -def node_prototype_index(caller): +def node_key(caller): metaprot = _get_menu_metaprot(caller) - text = [" |c--- Prototype menu --- |n" - ] + prot = metaprot.prototype + key = prot.get("key") - pass + text = ["Set the prototype's |ykey|n."] + if key: + text.append("Current key value is '|y{}|n'.") + else: + text.append("Key is currently unset.") + text = "\n\n".join(text) + options = _wizard_options("meta_locks", + + return "\n".join(text), options def start_olc(caller, session=None, metaproto=None): @@ -853,14 +921,13 @@ def start_olc(caller, session=None, metaproto=None): prototype rather than creating a new one. """ - - menudata = {"node_meta_index": node_meta_index, + menudata = {"node_index": node_index, "node_meta_key": node_meta_key, "node_meta_desc": node_meta_desc, "node_meta_tags": node_meta_tags, "node_meta_locks": node_meta_locks, - "node_prototype_index": node_prototype_index} - EvMenu(caller, menudata, startnode='node_meta_index', session=session, olc_metaproto=metaproto) + "node_key": node_key} + EvMenu(caller, menudata, startnode='node_index', session=session, olc_metaproto=metaproto) # Testing From b799b7280aef1e88b9f3a5c1f9bb8dade5bcbf60 Mon Sep 17 00:00:00 2001 From: Griatch Date: Sat, 24 Mar 2018 20:45:17 +0100 Subject: [PATCH 071/208] Add all spawn-menu nodes; need better validation/choices for several nodes --- evennia/utils/spawner.py | 345 ++++++++++++++++++++++++++++++++------- 1 file changed, 286 insertions(+), 59 deletions(-) diff --git a/evennia/utils/spawner.py b/evennia/utils/spawner.py index 2f31da0f49..25298476c6 100644 --- a/evennia/utils/spawner.py +++ b/evennia/utils/spawner.py @@ -725,7 +725,6 @@ def _set_property(caller, raw_string, **kwargs): meta = propname_low.startswith("meta_") if meta: propname_low = propname_low[5:] - raw_string = raw_string.strip() if callable(processor): try: @@ -763,25 +762,27 @@ def _wizard_options(prev_node, next_node): return options +# menu nodes + def node_index(caller): metaprot = _get_menu_metaprot(caller) prototype = metaprot.prototype text = ("|c --- Prototype wizard --- |n\n\n" - "Define properties of the prototype. All prototype values can be over-ridden at " - "the time of spawning an instance of the prototype, but some are required.\n\n" - "'Meta'-properties are not used in the prototype itself but are used to organize and " - "list prototypes. The 'Meta-Key' uniquely identifies the prototype and allows you to " - "edit an existing prototype or save a new one for use by you or others later.\n\n" - "(make choice; q to abort. If unsure, start from 1.)") + "Define the |yproperties|n of the prototype. All prototype values can be " + "over-ridden at the time of spawning an instance of the prototype, but some are " + "required.\n\n'|wMeta'-properties|n are not used in the prototype itself but are used " + "to organize and list prototypes. The 'Meta-Key' uniquely identifies the prototype " + "and allows you to edit an existing prototype or save a new one for use by you or " + "others later.\n\n(make choice; q to abort. If unsure, start from 1.)") options = [] # The meta-key goes first options.append( {"desc": "|WMeta-Key|n|n{}".format(_format_property("Key", True, metaprot, None)), "goto": "node_meta_key"}) - for key in ('Prototype', 'Typeclass', 'Key', 'Aliases', 'Home', 'Destination', - 'Permissions', 'Locks', 'Location', 'Tags', 'Attrs'): + for key in ('Prototype', 'Typeclass', 'Key', 'Aliases', 'Attrs', 'Tags', 'Locks', + 'Permissions', 'Location', 'Home', 'Destination'): req = False if key in ("Prototype", "Typeclass"): req = "prototype" not in prototype and "typeclass" not in prototype @@ -812,84 +813,66 @@ def _check_meta_key(caller, key): del caller.ndb._menutree.olc_new caller.ndb._menutree.olc_metaprot = old_metaprot caller.msg("Prototype already exists. Reloading.") - return "node_meta_index" + return "node_index" - return _set_property(caller, key, prop='meta_key', next_node="node_meta_desc") + return _set_property(caller, key, prop='meta_key', next_node="node_prototype") def node_meta_key(caller): metaprot = _get_menu_metaprot(caller) - text = ["The prototype name, or |cmeta-key|n, uniquely identifies the prototype. " + text = ["The prototype name, or |wMeta-Key|n, uniquely identifies the prototype. " "It is used to find and use the prototype to spawn new entities. " "It is not case sensitive."] old_key = metaprot.key if old_key: - text.append("Current key is '|y{key}|n'".format(key=old_key)) + text.append("Current key is '|w{key}|n'".format(key=old_key)) else: text.append("The key is currently unset.") text.append("Enter text or make a choice (q for quit)") text = "\n\n".join(text) - options = _wizard_options("index", "meta_desc") + options = _wizard_options("index", "prototype") options.append({"key": "_default", "goto": _check_meta_key}) return text, options -def node_meta_desc(caller): - +def node_prototype(caller): metaprot = _get_menu_metaprot(caller) - text = ["|cDescribe|n briefly the prototype for viewing in listings."] - desc = metaprot.desc + prot = metaprot.prototype + prototype = prot.get("prototype") - if desc: - text.append("The current meta desc is:\n\"|y{desc}|n\"".format(desc=desc)) + text = ["Set the prototype's parent |yPrototype|n. If this is unset, Typeclass will be used."] + if prototype: + text.append("Current prototype is |y{prototype}|n.".format(prototype=prototype)) else: - text.append("Description is currently unset.") + text.append("Parent prototype is not set") text = "\n\n".join(text) - options = _wizard_options("meta_key", "meta_tags") + options = _wizard_options("meta_key", "typeclass") options.append({"key": "_default", "goto": (_set_property, - dict(prop='meta_desc', next_node="node_meta_tags"))}) + dict(prop="prototype", + processor=lambda s: s.strip(), + next_node="node_typeclass"))}) return text, options -def node_meta_tags(caller): +def node_typeclass(caller): metaprot = _get_menu_metaprot(caller) - text = ["|cTags|n can be used to classify and find prototypes. Tags are case-insensitive. " - "Separate multiple by tags by commas."] - tags = metaprot.tags + prot = metaprot.prototype + typeclass = prot.get("typeclass") - if tags: - text.append("The current tags are:\n|y{tags}|n".format(tags=tags)) + text = ["Set the typeclass's parent |yTypeclass|n."] + if typeclass: + text.append("Current typeclass is |y{typeclass}|n.".format(typeclass=typeclass)) else: - text.append("No tags are currently set.") + text.append("Using default typeclass {typeclass}.".format( + typeclass=settings.BASE_OBJECT_TYPECLASS)) text = "\n\n".join(text) - options = _wizard_options("meta_desc", "meta_locks") + options = _wizard_options("prototype", "key") options.append({"key": "_default", "goto": (_set_property, - dict(prop="meta_tags", - processor=lambda s: [str(part.strip()) for part in s.split(",")], - next_node="node_meta_locks"))}) - return text, options - - -def node_meta_locks(caller): - metaprot = _get_menu_metaprot(caller) - text = ["Set |ylocks|n on the prototype. There are two valid lock types: " - "'edit' (who can edit the prototype) and 'use' (who can apply the prototype)\n" - "(If you are unsure, leave as default.)"] - locks = metaprot.locks - if locks: - text.append("Current lock is |y'{lockstring}'|n".format(lockstring=locks)) - else: - text.append("Lock unset - if not changed the default lockstring will be set as\n" - " |y'use:all(); edit:id({dbref}) or perm(Admin)'|n".format(dbref=caller.id)) - text = "\n\n".join(text) - options = _wizard_options("meta_tags", "prototype") - options.append({"key": "_default", - "desc": "enter lockstring", - "goto": (_set_property, - dict(prop="meta_locks", + dict(prop="typeclass", + processor=lambda s: s.strip(), next_node="node_key"))}) return text, options @@ -899,15 +882,248 @@ def node_key(caller): prot = metaprot.prototype key = prot.get("key") - text = ["Set the prototype's |ykey|n."] + text = ["Set the prototype's |yKey|n. This will retain case sensitivity."] if key: - text.append("Current key value is '|y{}|n'.") + text.append("Current key value is '|y{key}|n'.".format(key=key)) else: text.append("Key is currently unset.") text = "\n\n".join(text) - options = _wizard_options("meta_locks", + options = _wizard_options("typeclass", "aliases") + options.append({"key": "_default", + "goto": (_set_property, + dict(prop="key", + processor=lambda s: s.strip(), + next_node="node_aliases"))}) + return text, options + + +def node_aliases(caller): + metaprot = _get_menu_metaprot(caller) + prot = metaprot.prototype + aliases = prot.get("aliases") + + text = ["Set the prototype's |yAliases|n. Separate multiple aliases with commas. " + "ill retain case sensitivity."] + if aliases: + text.append("Current aliases are '|y{aliases}|n'.".format(aliases=aliases)) + else: + text.append("No aliases are set.") + text = "\n\n".join(text) + options = _wizard_options("key", "attrs") + options.append({"key": "_default", + "goto": (_set_property, + dict(prop="aliases", + processor=lambda s: [part.strip() for part in s.split(",")], + next_node="node_attrs"))}) + return text, options + + +def node_attrs(caller): + metaprot = _get_menu_metaprot(caller) + prot = metaprot.prototype + attrs = prot.get("attrs") + + text = ["Set the prototype's |yAttributes|n. Separate multiple attrs with commas. " + "Will retain case sensitivity."] + if attrs: + text.append("Current attrs are '|y{attrs}|n'.".format(attrs=attrs)) + else: + text.append("No attrs are set.") + text = "\n\n".join(text) + options = _wizard_options("aliases", "tags") + options.append({"key": "_default", + "goto": (_set_property, + dict(prop="attrs", + processor=lambda s: [part.strip() for part in s.split(",")], + next_node="node_tags"))}) + return text, options + + +def node_tags(caller): + metaprot = _get_menu_metaprot(caller) + prot = metaprot.prototype + tags = prot.get("tags") + + text = ["Set the prototype's |yTags|n. Separate multiple tags with commas. " + "Will retain case sensitivity."] + if tags: + text.append("Current tags are '|y{tags}|n'.".format(tags=tags)) + else: + text.append("No tags are set.") + text = "\n\n".join(text) + options = _wizard_options("attrs", "locks") + options.append({"key": "_default", + "goto": (_set_property, + dict(prop="tags", + processor=lambda s: [part.strip() for part in s.split(",")], + next_node="node_locks"))}) + return text, options + + +def node_locks(caller): + metaprot = _get_menu_metaprot(caller) + prot = metaprot.prototype + locks = prot.get("locks") + + text = ["Set the prototype's |yLock string|n. Separate multiple locks with semi-colons. " + "Will retain case sensitivity."] + if locks: + text.append("Current locks are '|y{locks}|n'.".format(locks=locks)) + else: + text.append("No locks are set.") + text = "\n\n".join(text) + options = _wizard_options("tags", "permissions") + options.append({"key": "_default", + "goto": (_set_property, + dict(prop="locks", + processor=lambda s: s.strip(), + next_node="node_permissions"))}) + return text, options + + +def node_permissions(caller): + metaprot = _get_menu_metaprot(caller) + prot = metaprot.prototype + permissions = prot.get("permissions") + + text = ["Set the prototype's |yPermissions|n. Separate multiple permissions with commas. " + "Will retain case sensitivity."] + if permissions: + text.append("Current permissions are '|y{permissions}|n'.".format(permissions=permissions)) + else: + text.append("No permissions are set.") + text = "\n\n".join(text) + options = _wizard_options("destination", "location") + options.append({"key": "_default", + "goto": (_set_property, + dict(prop="permissions", + processor=lambda s: [part.strip() for part in s.split(",")], + next_node="node_location"))}) + return text, options + + +def node_location(caller): + metaprot = _get_menu_metaprot(caller) + prot = metaprot.prototype + location = prot.get("location") + + text = ["Set the prototype's |yLocation|n"] + if location: + text.append("Current location is |y{location}|n.".format(location=location)) + else: + text.append("Default location is {}'s inventory.".format(caller)) + text = "\n\n".join(text) + options = _wizard_options("permissions", "home") + options.append({"key": "_default", + "goto": (_set_property, + dict(prop="location", + processor=lambda s: s.strip(), + next_node="node_home"))}) + return text, options + + +def node_home(caller): + metaprot = _get_menu_metaprot(caller) + prot = metaprot.prototype + home = prot.get("home") + + text = ["Set the prototype's |yHome location|n"] + if home: + text.append("Current home location is |y{home}|n.".format(home=home)) + else: + text.append("Default home location (|y{home}|n) used.".format(home=settings.DEFAULT_HOME)) + text = "\n\n".join(text) + options = _wizard_options("aliases", "destination") + options.append({"key": "_default", + "goto": (_set_property, + dict(prop="home", + processor=lambda s: s.strip(), + next_node="node_destination"))}) + return text, options + + +def node_destination(caller): + metaprot = _get_menu_metaprot(caller) + prot = metaprot.prototype + dest = prot.get("dest") + + text = ["Set the prototype's |yDestination|n. This is usually only used for Exits."] + if dest: + text.append("Current destination is |y{dest}|n.".format(dest=dest)) + else: + text.append("No destination is set (default).") + text = "\n\n".join(text) + options = _wizard_options("home", "meta_desc") + options.append({"key": "_default", + "goto": (_set_property, + dict(prop="dest", + processor=lambda s: s.strip(), + next_node="node_meta_desc"))}) + return text, options + + +def node_meta_desc(caller): + + metaprot = _get_menu_metaprot(caller) + text = ["The |wMeta-Description|n briefly describes the prototype for viewing in listings."] + desc = metaprot.desc + + if desc: + text.append("The current meta desc is:\n\"|w{desc}|n\"".format(desc=desc)) + else: + text.append("Description is currently unset.") + text = "\n\n".join(text) + options = _wizard_options("meta_key", "meta_tags") + options.append({"key": "_default", + "goto": (_set_property, + dict(prop='meta_desc', + processor=lambda s: s.strip(), + next_node="node_meta_tags"))}) + + return text, options + + +def node_meta_tags(caller): + metaprot = _get_menu_metaprot(caller) + text = ["|wMeta-Tags|n can be used to classify and find prototypes. Tags are case-insensitive. " + "Separate multiple by tags by commas."] + tags = metaprot.tags + + if tags: + text.append("The current tags are:\n|w{tags}|n".format(tags=tags)) + else: + text.append("No tags are currently set.") + text = "\n\n".join(text) + options = _wizard_options("meta_desc", "meta_locks") + options.append({"key": "_default", + "goto": (_set_property, + dict(prop="meta_tags", + processor=lambda s: [ + str(part.strip().lower()) for part in s.split(",")], + next_node="node_meta_locks"))}) + return text, options + + +def node_meta_locks(caller): + metaprot = _get_menu_metaprot(caller) + text = ["Set |wMeta-Locks|n on the prototype. There are two valid lock types: " + "'edit' (who can edit the prototype) and 'use' (who can apply the prototype)\n" + "(If you are unsure, leave as default.)"] + locks = metaprot.locks + if locks: + text.append("Current lock is |w'{lockstring}'|n".format(lockstring=locks)) + else: + text.append("Lock unset - if not changed the default lockstring will be set as\n" + " |w'use:all(); edit:id({dbref}) or perm(Admin)'|n".format(dbref=caller.id)) + text = "\n\n".join(text) + options = _wizard_options("meta_tags", "index") + options.append({"key": "_default", + "goto": (_set_property, + dict(prop="meta_locks", + processor=lambda s: s.strip().lower(), + next_node="node_index"))}) + return text, options - return "\n".join(text), options def start_olc(caller, session=None, metaproto=None): @@ -923,10 +1139,21 @@ def start_olc(caller, session=None, metaproto=None): """ menudata = {"node_index": node_index, "node_meta_key": node_meta_key, + "node_prototype": node_prototype, + "node_typeclass": node_typeclass, + "node_key": node_key, + "node_aliases": node_aliases, + "node_attrs": node_attrs, + "node_tags": node_tags, + "node_locks": node_locks, + "node_permissions": node_permissions, + "node_location": node_location, + "node_home": node_home, + "node_destination": node_destination, "node_meta_desc": node_meta_desc, "node_meta_tags": node_meta_tags, "node_meta_locks": node_meta_locks, - "node_key": node_key} + } EvMenu(caller, menudata, startnode='node_index', session=session, olc_metaproto=metaproto) From ca746f9af2fa1e123cc4c477fa9ebc338e246ebc Mon Sep 17 00:00:00 2001 From: Griatch Date: Wed, 28 Mar 2018 00:02:00 +0200 Subject: [PATCH 072/208] Start add list_node EvMenu node decorator --- evennia/utils/evmenu.py | 102 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 102 insertions(+) diff --git a/evennia/utils/evmenu.py b/evennia/utils/evmenu.py index 94c1467419..148ba4c0dc 100644 --- a/evennia/utils/evmenu.py +++ b/evennia/utils/evmenu.py @@ -166,6 +166,7 @@ evennia.utils.evmenu`. from __future__ import print_function import random from builtins import object, range +import re from textwrap import dedent from inspect import isfunction, getargspec @@ -972,6 +973,107 @@ class EvMenu(object): return separator1 + "|n" + nodetext + "|n" + separator2 + "|n" + optionstext +# ----------------------------------------------------------- +# +# List node +# +# ----------------------------------------------------------- + +def list_node(option_list, examine_processor, goto_processor, pagesize=10): + """ + Decorator for making an EvMenu node into a multi-page list node. Will add new options, + prepending those options added in the node. + + Args: + option_list (list): List of strings indicating the options. + examine_processor (callable): Will be called with the caller and the chosen option when + examining said option. Should return a text string to display in the node. + goto_processor (callable): Will be called with caller and + the chosen option from the optionlist. Should return the target node to goto after the + selection. + pagesize (int): How many options to show per page. + + Example: + + @list_node(['foo', 'bar'], examine_processor, goto_processor) + def node_index(caller): + text = "describing the list" + return text, [] + + """ + + def _rerouter(caller, raw_string): + "Parse which input was given, select from option_list" + + caller.ndb._menutree + + goto_processor + + + + def decorator(func): + + all_options = [{"desc": opt, "goto": _rerouter} for opt in option_list] + all_options = list(sorted(all_options, key=lambda d: d["desc"])) + + nall_options = len(all_options) + pages = [all_options[ind:ind + pagesize] for ind in range(0, nall_options, pagesize)] + npages = len(pages) + + def _examine_select(caller, raw_string, **kwargs): + + match = re.search(r"[0-9]+$", raw_string) + + + page_index = kwargs.get("optionpage_index", 0) + + + def _list_node(caller, raw_string, **kwargs): + + # update text with detail, if set + + + # dynamic, multi-page option list + page_index = max(0, min(npages - 1, kwargs.get("optionpage_index", 0))) + + options = pages[page_index] + + if options: + if npages > 1: + # if the goto callable returns None, the same node is rerun, and + # kwargs not used by the callable are passed on to the node. + if page_index > 0: + options.append({"desc": "prev", + "goto": (lambda caller: None, + {"optionpage_index": page_index - 1})}) + if page_index < npages - 1: + options.append({"desc": "next", + "goto": (lambda caller: None, + {"optionpage_index": page_index + 1})}) + options.append({"key": "_default", + "goto": (_examine_select, {"optionpage_index": page_index})}) + + # add data from the decorated node + + try: + text, extra_options = func(caller, raw_string) + except Exception: + logger.log_trace() + else: + if isinstance(extra_options, {}): + extra_options = [extra_options] + else: + extra_options = make_iter(extra_options) + options.append(extra_options) + + return text, options + + return _list_node + return decorator + + + + # ------------------------------------------------------------------------------------------------- # # Simple input shortcuts From 058a3085e4c0efc955b93c6a9472f5af6c110ca2 Mon Sep 17 00:00:00 2001 From: Griatch Date: Wed, 28 Mar 2018 23:56:23 +0200 Subject: [PATCH 073/208] Almost working list_node evmenu decorator --- evennia/utils/evmenu.py | 105 +++++++++++++++++++++++---------------- evennia/utils/spawner.py | 15 +++++- 2 files changed, 76 insertions(+), 44 deletions(-) diff --git a/evennia/utils/evmenu.py b/evennia/utils/evmenu.py index 148ba4c0dc..6c8729aee1 100644 --- a/evennia/utils/evmenu.py +++ b/evennia/utils/evmenu.py @@ -1002,61 +1002,82 @@ def list_node(option_list, examine_processor, goto_processor, pagesize=10): """ - def _rerouter(caller, raw_string): - "Parse which input was given, select from option_list" - - caller.ndb._menutree - - goto_processor - - - def decorator(func): - all_options = [{"desc": opt, "goto": _rerouter} for opt in option_list] - all_options = list(sorted(all_options, key=lambda d: d["desc"])) + def _input_parser(caller, raw_string, **kwargs): + "Parse which input was given, select from option_list" - nall_options = len(all_options) - pages = [all_options[ind:ind + pagesize] for ind in range(0, nall_options, pagesize)] + available_choices = kwargs.get("available_choices", []) + processor = kwargs.get("selection_processor") + try: + match_ind = int(re.search(r"[0-9]+$", raw_string).group()) - 1 + selection = available_choices[match_ind] + except (AttributeError, KeyError, IndexError, ValueError): + return None + + if processor: + try: + return processor(caller, selection) + except Exception: + logger.log_trace() + return selection + + nall_options = len(option_list) + pages = [option_list[ind:ind + pagesize] for ind in range(0, nall_options, pagesize)] npages = len(pages) - def _examine_select(caller, raw_string, **kwargs): - - match = re.search(r"[0-9]+$", raw_string) - - - page_index = kwargs.get("optionpage_index", 0) - - def _list_node(caller, raw_string, **kwargs): - # update text with detail, if set - - - # dynamic, multi-page option list page_index = max(0, min(npages - 1, kwargs.get("optionpage_index", 0))) + page = pages[page_index] - options = pages[page_index] + # dynamic, multi-page option list. We use _input_parser as a goto-callable, + # with the `goto_processor` redirecting when we leave the node. + options = [{"desc": opt, + "goto": (_input_parser, + {"available_choices": page, + "selection_processor": goto_processor})} for opt in page] - if options: - if npages > 1: - # if the goto callable returns None, the same node is rerun, and - # kwargs not used by the callable are passed on to the node. - if page_index > 0: - options.append({"desc": "prev", - "goto": (lambda caller: None, - {"optionpage_index": page_index - 1})}) - if page_index < npages - 1: - options.append({"desc": "next", - "goto": (lambda caller: None, - {"optionpage_index": page_index + 1})}) - options.append({"key": "_default", - "goto": (_examine_select, {"optionpage_index": page_index})}) + if npages > 1: + # if the goto callable returns None, the same node is rerun, and + # kwargs not used by the callable are passed on to the node. This + # allows us to call ourselves over and over, using different kwargs. + if page_index > 0: + options.append({"key": ("|wb|Wack|n", "b"), + "goto": (lambda caller: None, + {"optionpage_index": page_index - 1})}) + if page_index < npages - 1: + options.append({"key": ("|wn|Wext|n", "n"), + "goto": (lambda caller: None, + {"optionpage_index": page_index + 1})}) + + options.append({"key": ("|Wcurrent|n", "c"), + "desc": "|W({}/{})|n".format(page_index + 1, npages), + "goto": (lambda caller: None, + {"optionpage_index": page_index})}) + options.append({"key": "_default", + "goto": (lambda caller: None, + {"show_detail": True, "optionpage_index": page_index})}) + + # update text with detail, if set. Here we call _input_parser like a normal function + text_detail = None + if raw_string and 'show_detail' in kwargs: + text_detail = _input_parser( + caller, raw_string, **{"available_choices": page, + "selection_processor": examine_processor}) + if text_detail is None: + text_detail = "|rThat's not a valid command or option.|n" # add data from the decorated node + text = '' try: text, extra_options = func(caller, raw_string) + except TypeError: + try: + text, extra_options = func(caller) + except Exception: + raise except Exception: logger.log_trace() else: @@ -1066,14 +1087,14 @@ def list_node(option_list, examine_processor, goto_processor, pagesize=10): extra_options = make_iter(extra_options) options.append(extra_options) + text = text + "\n\n" + text_detail if text_detail else text + return text, options return _list_node return decorator - - # ------------------------------------------------------------------------------------------------- # # Simple input shortcuts diff --git a/evennia/utils/spawner.py b/evennia/utils/spawner.py index 25298476c6..3b2d6d4353 100644 --- a/evennia/utils/spawner.py +++ b/evennia/utils/spawner.py @@ -109,13 +109,14 @@ from django.conf import settings from random import randint import evennia from evennia.objects.models import ObjectDB -from evennia.utils.utils import make_iter, all_from_module, dbid_to_obj, is_iter, crop +from evennia.utils.utils import ( + make_iter, all_from_module, dbid_to_obj, is_iter, crop, get_all_typeclasses) from collections import namedtuple from evennia.scripts.scripts import DefaultScript from evennia.utils.create import create_script from evennia.utils.evtable import EvTable -from evennia.utils.evmenu import EvMenu +from evennia.utils.evmenu import EvMenu, list_node _CREATE_OBJECT_KWARGS = ("key", "location", "home", "destination") @@ -856,6 +857,16 @@ def node_prototype(caller): return text, options +def _typeclass_examine(caller, typeclass): + return "This is typeclass |y{}|n.".format(typeclass) + + +def _typeclass_select(caller, typeclass): + caller.msg("Selected typeclass |y{}|n.".format(typeclass)) + return None + + +@list_node(list(sorted(get_all_typeclasses().keys())), _typeclass_examine, _typeclass_select) def node_typeclass(caller): metaprot = _get_menu_metaprot(caller) prot = metaprot.prototype From 9f3c3c5cb90d3c35970ed84ce7ba3a1b1ffd9b73 Mon Sep 17 00:00:00 2001 From: Rachel Blackman Date: Thu, 29 Mar 2018 18:02:45 -0700 Subject: [PATCH 074/208] Add 'INFO' command to unlogged-in command set, so that we can be polled by Mudconnector and Mudstats. --- evennia/commands/default/cmdset_unloggedin.py | 1 + evennia/commands/default/unloggedin.py | 21 ++++++++++++++++++- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/evennia/commands/default/cmdset_unloggedin.py b/evennia/commands/default/cmdset_unloggedin.py index 5e43d5e8c8..1c45d908aa 100644 --- a/evennia/commands/default/cmdset_unloggedin.py +++ b/evennia/commands/default/cmdset_unloggedin.py @@ -23,3 +23,4 @@ class UnloggedinCmdSet(CmdSet): self.add(unloggedin.CmdUnconnectedHelp()) self.add(unloggedin.CmdUnconnectedEncoding()) self.add(unloggedin.CmdUnconnectedScreenreader()) + self.add(unloggedin.CmdUnconnectedInfo()) diff --git a/evennia/commands/default/unloggedin.py b/evennia/commands/default/unloggedin.py index 429948b0fb..28c126e6d8 100644 --- a/evennia/commands/default/unloggedin.py +++ b/evennia/commands/default/unloggedin.py @@ -3,6 +3,7 @@ Commands that are available from the connect screen. """ import re import time +import datetime from collections import defaultdict from random import getrandbits from django.conf import settings @@ -11,8 +12,9 @@ from evennia.accounts.models import AccountDB from evennia.objects.models import ObjectDB from evennia.server.models import ServerConfig from evennia.comms.models import ChannelDB +from evennia.server.sessionhandler import SESSIONS -from evennia.utils import create, logger, utils +from evennia.utils import create, logger, utils, gametime from evennia.commands.cmdhandler import CMD_LOGINSTART COMMAND_DEFAULT_CLASS = utils.class_from_module(settings.COMMAND_DEFAULT_CLASS) @@ -516,6 +518,23 @@ class CmdUnconnectedScreenreader(COMMAND_DEFAULT_CLASS): self.session.sessionhandler.session_portal_sync(self.session) +class CmdUnconnectedInfo(COMMAND_DEFAULT_CLASS): + """ + Provides MUDINFO output, so that Evennia games can be added to Mudconnector + and Mudstats. + """ + key = "info" + locks = "cmd:all()" + + def func(self): + self.caller.msg("## BEGIN INFO 1.1") + self.caller.msg("Name: %s" % settings.SERVERNAME) + self.caller.msg("Uptime: %s" % datetime.datetime.fromtimestamp(gametime.SERVER_START_TIME).ctime()) + self.caller.msg("Connected: %d" % SESSIONS.account_count()) + self.caller.msg("Version: Evennia %s" % utils.get_evennia_version()) + self.caller.msg("## END INFO") + + def _create_account(session, accountname, password, permissions, typeclass=None, email=None): """ Helper function, creates an account of the specified typeclass. From e875717b327d67df6495d8f6ae0f6f0149911d01 Mon Sep 17 00:00:00 2001 From: Griatch Date: Sat, 31 Mar 2018 12:40:57 +0200 Subject: [PATCH 075/208] Fix of output handling in msg() when text is None --- evennia/accounts/accounts.py | 16 +++++++++------- evennia/objects/objects.py | 16 +++++++++------- 2 files changed, 18 insertions(+), 14 deletions(-) diff --git a/evennia/accounts/accounts.py b/evennia/accounts/accounts.py index 48d03d8dc8..fe7693cce0 100644 --- a/evennia/accounts/accounts.py +++ b/evennia/accounts/accounts.py @@ -421,17 +421,19 @@ class DefaultAccount(with_metaclass(TypeclassBase, AccountDB)): kwargs["options"] = options - if not (isinstance(text, basestring) or isinstance(text, tuple)): - # sanitize text before sending across the wire - try: - text = to_str(text, force_string=True) - except Exception: - text = repr(text) + if text is not None: + if not (isinstance(text, basestring) or isinstance(text, tuple)): + # sanitize text before sending across the wire + try: + text = to_str(text, force_string=True) + except Exception: + text = repr(text) + kwargs['text'] = text # session relay sessions = make_iter(session) if session else self.sessions.all() for session in sessions: - session.data_out(text=text, **kwargs) + session.data_out(**kwargs) def execute_cmd(self, raw_string, session=None, **kwargs): """ diff --git a/evennia/objects/objects.py b/evennia/objects/objects.py index f583570707..8cdf546706 100644 --- a/evennia/objects/objects.py +++ b/evennia/objects/objects.py @@ -535,17 +535,19 @@ class DefaultObject(with_metaclass(TypeclassBase, ObjectDB)): except Exception: logger.log_trace() - if not (isinstance(text, basestring) or isinstance(text, tuple)): - # sanitize text before sending across the wire - try: - text = to_str(text, force_string=True) - except Exception: - text = repr(text) + if text is not None: + if not (isinstance(text, basestring) or isinstance(text, tuple)): + # sanitize text before sending across the wire + try: + text = to_str(text, force_string=True) + except Exception: + text = repr(text) + kwargs['text'] = text # relay to session(s) sessions = make_iter(session) if session else self.sessions.all() for session in sessions: - session.data_out(text=text, **kwargs) + session.data_out(**kwargs) def for_contents(self, func, exclude=None, **kwargs): From 1689d54ff3631a8ba224ac72a86c97f3e888b522 Mon Sep 17 00:00:00 2001 From: Griatch Date: Sat, 31 Mar 2018 21:10:20 +0200 Subject: [PATCH 076/208] New list_node decorator for evmenu. Tested with olc menu --- evennia/commands/default/building.py | 16 +-- evennia/utils/evmenu.py | 52 ++++++---- evennia/utils/spawner.py | 141 +++++++++++++++++++++------ evennia/utils/utils.py | 9 +- 4 files changed, 152 insertions(+), 66 deletions(-) diff --git a/evennia/commands/default/building.py b/evennia/commands/default/building.py index dc70e52cea..759247acc7 100644 --- a/evennia/commands/default/building.py +++ b/evennia/commands/default/building.py @@ -15,7 +15,8 @@ from evennia.utils.eveditor import EvEditor from evennia.utils.evmore import EvMore from evennia.utils.spawner import (spawn, search_prototype, list_prototypes, save_db_prototype, build_metaproto, validate_prototype, - delete_db_prototype, PermissionError, start_olc) + delete_db_prototype, PermissionError, start_olc, + metaproto_to_str) from evennia.utils.ansi import raw COMMAND_DEFAULT_CLASS = class_from_module(settings.COMMAND_DEFAULT_CLASS) @@ -2886,21 +2887,10 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): def _search_show_prototype(query, metaprots=None): # prototype detail - strings = [] if not metaprots: metaprots = search_prototype(key=query, return_meta=True) if metaprots: - for metaprot in metaprots: - header = ( - "|cprototype key:|n {}, |ctags:|n {}, |clocks:|n {} \n" - "|cdesc:|n {} \n|cprototype:|n ".format( - metaprot.key, ", ".join(metaprot.tags), - metaprot.locks, metaprot.desc)) - prototype = ("{{\n {} \n}}".format("\n ".join("{!r}: {!r},".format(key, value) - for key, value in - sorted(metaprot.prototype.items())).rstrip(","))) - strings.append(header + prototype) - return "\n".join(strings) + return "\n".join(metaproto_to_str(metaprot) for metaprot in metaprots) else: return False diff --git a/evennia/utils/evmenu.py b/evennia/utils/evmenu.py index 6c8729aee1..cccda6798f 100644 --- a/evennia/utils/evmenu.py +++ b/evennia/utils/evmenu.py @@ -979,18 +979,20 @@ class EvMenu(object): # # ----------------------------------------------------------- -def list_node(option_list, examine_processor, goto_processor, pagesize=10): +def list_node(option_generator, examine_processor, goto_processor, pagesize=10): """ Decorator for making an EvMenu node into a multi-page list node. Will add new options, prepending those options added in the node. Args: - option_list (list): List of strings indicating the options. - examine_processor (callable): Will be called with the caller and the chosen option when - examining said option. Should return a text string to display in the node. - goto_processor (callable): Will be called with caller and - the chosen option from the optionlist. Should return the target node to goto after the - selection. + option_generator (callable or list): A list of strings indicating the options, or a callable + that is called without any arguments to produce such a list. + examine_processor (callable, optional): Will be called with the caller and the chosen option + when examining said option. Should return a text string to display in the node. + goto_processor (callable, optional): Will be called as goto_processor(caller, menuchoice) + where menuchoice is the chosen option as a string. Should return the target node to + goto after this selection. + pagesize (int): How many options to show per page. Example: @@ -1009,6 +1011,7 @@ def list_node(option_list, examine_processor, goto_processor, pagesize=10): available_choices = kwargs.get("available_choices", []) processor = kwargs.get("selection_processor") + try: match_ind = int(re.search(r"[0-9]+$", raw_string).group()) - 1 selection = available_choices[match_ind] @@ -1022,12 +1025,14 @@ def list_node(option_list, examine_processor, goto_processor, pagesize=10): logger.log_trace() return selection - nall_options = len(option_list) - pages = [option_list[ind:ind + pagesize] for ind in range(0, nall_options, pagesize)] - npages = len(pages) - def _list_node(caller, raw_string, **kwargs): + option_list = option_generator() if callable(option_generator) else option_generator + + nall_options = len(option_list) + pages = [option_list[ind:ind + pagesize] for ind in range(0, nall_options, pagesize)] + npages = len(pages) + page_index = max(0, min(npages - 1, kwargs.get("optionpage_index", 0))) page = pages[page_index] @@ -1042,19 +1047,21 @@ def list_node(option_list, examine_processor, goto_processor, pagesize=10): # if the goto callable returns None, the same node is rerun, and # kwargs not used by the callable are passed on to the node. This # allows us to call ourselves over and over, using different kwargs. - if page_index > 0: - options.append({"key": ("|wb|Wack|n", "b"), - "goto": (lambda caller: None, - {"optionpage_index": page_index - 1})}) - if page_index < npages - 1: - options.append({"key": ("|wn|Wext|n", "n"), - "goto": (lambda caller: None, - {"optionpage_index": page_index + 1})}) - options.append({"key": ("|Wcurrent|n", "c"), "desc": "|W({}/{})|n".format(page_index + 1, npages), "goto": (lambda caller: None, {"optionpage_index": page_index})}) + if page_index > 0: + options.append({"key": ("|wp|Wrevious page|n", "p"), + "goto": (lambda caller: None, + {"optionpage_index": page_index - 1})}) + if page_index < npages - 1: + options.append({"key": ("|wn|Wext page|n", "n"), + "goto": (lambda caller: None, + {"optionpage_index": page_index + 1})}) + + + # this catches arbitrary input, notably to examine entries ('look 4' or 'l4' etc) options.append({"key": "_default", "goto": (lambda caller: None, {"show_detail": True, "optionpage_index": page_index})}) @@ -1071,6 +1078,7 @@ def list_node(option_list, examine_processor, goto_processor, pagesize=10): # add data from the decorated node text = '' + extra_options = [] try: text, extra_options = func(caller, raw_string) except TypeError: @@ -1080,14 +1088,16 @@ def list_node(option_list, examine_processor, goto_processor, pagesize=10): raise except Exception: logger.log_trace() + print("extra_options:", extra_options) else: if isinstance(extra_options, {}): extra_options = [extra_options] else: extra_options = make_iter(extra_options) - options.append(extra_options) + options.extend(extra_options) text = text + "\n\n" + text_detail if text_detail else text + text += "\n\n(Make a choice or enter 'look ' to examine an option closer)" return text, options diff --git a/evennia/utils/spawner.py b/evennia/utils/spawner.py index 3b2d6d4353..8caa9ae0c2 100644 --- a/evennia/utils/spawner.py +++ b/evennia/utils/spawner.py @@ -333,7 +333,7 @@ def search_module_prototype(key=None, tags=None): def search_prototype(key=None, tags=None, return_meta=True): """ - Find prototypes based on key and/or tags. + Find prototypes based on key and/or tags, or all prototypes. Kwargs: key (str): An exact or partial key to query for. @@ -344,7 +344,8 @@ def search_prototype(key=None, tags=None, return_meta=True): return MetaProto namedtuples including prototype meta info Return: - matches (list): All found prototype dicts or MetaProtos + matches (list): All found prototype dicts or MetaProtos. If no keys + or tags are given, all available prototypes/MetaProtos will be returned. Note: The available prototypes is a combination of those supplied in @@ -438,6 +439,25 @@ def list_prototypes(caller, key=None, tags=None, show_non_use=False, show_non_ed table.reformat_column(3, width=20) return table + +def metaproto_to_str(metaproto): + """ + Format a metaproto to a nice string representation. + + Args: + metaproto (NamedTuple): Represents the prototype. + """ + header = ( + "|cprototype key:|n {}, |ctags:|n {}, |clocks:|n {} \n" + "|cdesc:|n {} \n|cprototype:|n ".format( + metaproto.key, ", ".join(metaproto.tags), + metaproto.locks, metaproto.desc)) + prototype = ("{{\n {} \n}}".format("\n ".join("{!r}: {!r},".format(key, value) + for key, value in + sorted(metaproto.prototype.items())).rstrip(","))) + return header + prototype + + # Spawner mechanism @@ -660,7 +680,13 @@ def spawn(*prototypes, **kwargs): return _batch_create_object(*objsparams) -# prototype design menu nodes +# ------------------------------------------------------------ +# +# OLC Prototype design menu +# +# ------------------------------------------------------------ + +# Helper functions def _get_menu_metaprot(caller): if hasattr(caller.ndb._menutree, "olc_metaprot"): @@ -683,7 +709,7 @@ def _set_menu_metaprot(caller, field, value): caller.ndb._menutree.olc_metaprot = build_metaproto(**kwargs) -def _format_property(key, required=False, metaprot=None, prototype=None): +def _format_property(key, required=False, metaprot=None, prototype=None, cropper=None): key = key.lower() if metaprot is not None: prop = getattr(metaprot, key) or '' @@ -700,7 +726,7 @@ def _format_property(key, required=False, metaprot=None, prototype=None): out = ", ".join(str(pr) for pr in prop) if not out and required: out = "|rrequired" - return " ({}|n)".format(crop(out, _MENU_CROP_WIDTH)) + return " ({}|n)".format(cropper(out) if cropper else crop(out, _MENU_CROP_WIDTH)) def _set_property(caller, raw_string, **kwargs): @@ -744,26 +770,43 @@ def _set_property(caller, raw_string, **kwargs): metaprot = _get_menu_metaprot(caller) prototype = metaprot.prototype prototype[propname_low] = value + + # typeclass and prototype can't co-exist + if propname_low == "typeclass": + prototype.pop("prototype", None) + if propname_low == "prototype": + prototype.pop("typeclass", None) + _set_menu_metaprot(caller, "prototype", prototype) + caller.msg("Set {prop} to {value}.".format( prop=prop.replace("_", "-").capitalize(), value=str(value))) return next_node -def _wizard_options(prev_node, next_node): - options = [{"desc": "forward ({})".format(next_node.replace("_", "-")), +def _wizard_options(prev_node, next_node, color="|W"): + options = [{"key": ("|wf|Worward", "f"), + "desc": "{color}({node})|n".format( + color=color, node=next_node.replace("_", "-")), "goto": "node_{}".format(next_node)}, - {"desc": "back ({})".format(prev_node.replace("_", "-")), + {"key": ("|wb|Wack", "b"), + "desc": "{color}({node})|n".format( + color=color, node=prev_node.replace("_", "-")), "goto": "node_{}".format(prev_node)}] if "index" not in (prev_node, next_node): - options.append({"desc": "index", + options.append({"key": ("|wi|Wndex", "i"), "goto": "node_index"}) return options -# menu nodes +def _path_cropper(pythonpath): + "Crop path to only the last component" + return pythonpath.split('.')[-1] + + +# Menu nodes def node_index(caller): metaprot = _get_menu_metaprot(caller) @@ -784,15 +827,20 @@ def node_index(caller): "goto": "node_meta_key"}) for key in ('Prototype', 'Typeclass', 'Key', 'Aliases', 'Attrs', 'Tags', 'Locks', 'Permissions', 'Location', 'Home', 'Destination'): - req = False + required = False + cropper = None if key in ("Prototype", "Typeclass"): - req = "prototype" not in prototype and "typeclass" not in prototype + required = "prototype" not in prototype and "typeclass" not in prototype + if key == 'Typeclass': + cropper = _path_cropper options.append( - {"desc": "|w{}|n{}".format(key, _format_property(key, req, None, prototype)), + {"desc": "|w{}|n{}".format( + key, _format_property(key, required, None, prototype, cropper=cropper)), "goto": "node_{}".format(key.lower())}) + required = False for key in ('Desc', 'Tags', 'Locks'): options.append( - {"desc": "|WMeta-{}|n|n{}".format(key, _format_property(key, req, metaprot, None)), + {"desc": "|WMeta-{}|n|n{}".format(key, _format_property(key, required, metaprot, None)), "goto": "node_meta_{}".format(key.lower())}) return text, options @@ -837,6 +885,24 @@ def node_meta_key(caller): return text, options +def _all_prototypes(): + return [mproto.key for mproto in search_prototype()] + + +def _prototype_examine(caller, prototype_name): + metaprot = search_prototype(key=prototype_name) + if metaprot: + return metaproto_to_str(metaprot[0]) + return "Prototype not registered." + + +def _prototype_select(caller, prototype): + ret = _set_property(caller, prototype, prop="prototype", processor=str, next_node="node_key") + caller.msg("Selected prototype |y{}|n. Removed any set typeclass parent.".format(prototype)) + return ret + + +@list_node(_all_prototypes, _prototype_examine, _prototype_select) def node_prototype(caller): metaprot = _get_menu_metaprot(caller) prot = metaprot.prototype @@ -848,25 +914,43 @@ def node_prototype(caller): else: text.append("Parent prototype is not set") text = "\n\n".join(text) - options = _wizard_options("meta_key", "typeclass") - options.append({"key": "_default", - "goto": (_set_property, - dict(prop="prototype", - processor=lambda s: s.strip(), - next_node="node_typeclass"))}) + options = _wizard_options("meta_key", "typeclass", color="|W") return text, options -def _typeclass_examine(caller, typeclass): - return "This is typeclass |y{}|n.".format(typeclass) +def _all_typeclasses(): + return list(sorted(get_all_typeclasses().keys())) + # return list(sorted(get_all_typeclasses(parent="evennia.objects.objects.DefaultObject").keys())) + + +def _typeclass_examine(caller, typeclass_path): + if typeclass_path is None: + # this means we are exiting the listing + return "node_key" + + typeclass = get_all_typeclasses().get(typeclass_path) + if typeclass: + docstr = [] + for line in typeclass.__doc__.split("\n"): + if line.strip(): + docstr.append(line) + elif docstr: + break + docstr = '\n'.join(docstr) if docstr else "" + txt = "Typeclass |y{typeclass_path}|n; First paragraph of docstring:\n\n{docstring}".format( + typeclass_path=typeclass_path, docstring=docstr) + else: + txt = "This is typeclass |y{}|n.".format(typeclass) + return txt def _typeclass_select(caller, typeclass): - caller.msg("Selected typeclass |y{}|n.".format(typeclass)) - return None + ret = _set_property(caller, typeclass, prop='typeclass', processor=str, next_node="node_key") + caller.msg("Selected typeclass |y{}|n. Removed any set prototype parent.".format(typeclass)) + return ret -@list_node(list(sorted(get_all_typeclasses().keys())), _typeclass_examine, _typeclass_select) +@list_node(_all_typeclasses, _typeclass_examine, _typeclass_select) def node_typeclass(caller): metaprot = _get_menu_metaprot(caller) prot = metaprot.prototype @@ -879,12 +963,7 @@ def node_typeclass(caller): text.append("Using default typeclass {typeclass}.".format( typeclass=settings.BASE_OBJECT_TYPECLASS)) text = "\n\n".join(text) - options = _wizard_options("prototype", "key") - options.append({"key": "_default", - "goto": (_set_property, - dict(prop="typeclass", - processor=lambda s: s.strip(), - next_node="node_key"))}) + options = _wizard_options("prototype", "key", color="|W") return text, options diff --git a/evennia/utils/utils.py b/evennia/utils/utils.py index a8d2171f75..22d59a165f 100644 --- a/evennia/utils/utils.py +++ b/evennia/utils/utils.py @@ -1882,10 +1882,14 @@ def get_game_dir_path(): raise RuntimeError("server/conf/settings.py not found: Must start from inside game dir.") -def get_all_typeclasses(): +def get_all_typeclasses(parent=None): """ List available typeclasses from all available modules. + Args: + parent (str, optional): If given, only return typeclasses inheriting (at any distance) + from this parent. + Returns: typeclasses (dict): On the form {"typeclass.path": typeclass, ...} @@ -1898,4 +1902,7 @@ def get_all_typeclasses(): from evennia.typeclasses.models import TypedObject typeclasses = {"{}.{}".format(model.__module__, model.__name__): model for model in apps.get_models() if TypedObject in getmro(model)} + if parent: + typeclasses = {name: typeclass for name, typeclass in typeclasses.items() + if inherits_from(typeclass, parent)} return typeclasses From 7dd6c2bd593947d029fe410159e4f0e6255cff09 Mon Sep 17 00:00:00 2001 From: Griatch Date: Sat, 31 Mar 2018 22:58:17 +0200 Subject: [PATCH 077/208] Custom OLCMenu class, validate prot from menu --- evennia/utils/spawner.py | 106 +++++++++++++++++++++++++++++---------- 1 file changed, 80 insertions(+), 26 deletions(-) diff --git a/evennia/utils/spawner.py b/evennia/utils/spawner.py index 8caa9ae0c2..c9a68ea8ab 100644 --- a/evennia/utils/spawner.py +++ b/evennia/utils/spawner.py @@ -117,6 +117,7 @@ from evennia.scripts.scripts import DefaultScript from evennia.utils.create import create_script from evennia.utils.evtable import EvTable from evennia.utils.evmenu import EvMenu, list_node +from evennia.utils.ansi import strip_ansi _CREATE_OBJECT_KWARGS = ("key", "location", "home", "destination") @@ -779,25 +780,33 @@ def _set_property(caller, raw_string, **kwargs): _set_menu_metaprot(caller, "prototype", prototype) - caller.msg("Set {prop} to {value}.".format( prop=prop.replace("_", "-").capitalize(), value=str(value))) return next_node -def _wizard_options(prev_node, next_node, color="|W"): - options = [{"key": ("|wf|Worward", "f"), - "desc": "{color}({node})|n".format( - color=color, node=next_node.replace("_", "-")), - "goto": "node_{}".format(next_node)}, - {"key": ("|wb|Wack", "b"), - "desc": "{color}({node})|n".format( - color=color, node=prev_node.replace("_", "-")), - "goto": "node_{}".format(prev_node)}] +def _wizard_options(curr_node, prev_node, next_node, color="|W"): + options = [] + if prev_node: + options.append({"key": ("|wb|Wack", "b"), + "desc": "{color}({node})|n".format( + color=color, node=prev_node.replace("_", "-")), + "goto": "node_{}".format(prev_node)}) + if next_node: + options.append({"key": ("|wf|Worward", "f"), + "desc": "{color}({node})|n".format( + color=color, node=next_node.replace("_", "-")), + "goto": "node_{}".format(next_node)}) + if "index" not in (prev_node, next_node): options.append({"key": ("|wi|Wndex", "i"), "goto": "node_index"}) + + if curr_node: + options.append({"key": ("|wv|Walidate prototype", "v"), + "goto": ("node_validate_prototype", {"back": curr_node})}) + return options @@ -846,6 +855,23 @@ def node_index(caller): return text, options +def node_validate_prototype(caller, raw_string, **kwargs): + metaprot = _get_menu_metaprot(caller) + + txt = metaproto_to_str(metaprot) + errors = "\n\n|g No validation errors found.|n (but errors could still happen at spawn-time)" + try: + # validate, don't spawn + spawn(metaprot.prototype, return_prototypes=True) + except RuntimeError as err: + errors = "\n\n|rError: {}|n".format(err) + text = (txt + errors) + + options = _wizard_options(None, kwargs.get("back"), None) + + return text, options + + def _check_meta_key(caller, key): old_metaprot = search_prototype(key) olc_new = caller.ndb._menutree.olc_new @@ -879,7 +905,7 @@ def node_meta_key(caller): text.append("The key is currently unset.") text.append("Enter text or make a choice (q for quit)") text = "\n\n".join(text) - options = _wizard_options("index", "prototype") + options = _wizard_options("meta_key", "index", "prototype") options.append({"key": "_default", "goto": _check_meta_key}) return text, options @@ -914,7 +940,7 @@ def node_prototype(caller): else: text.append("Parent prototype is not set") text = "\n\n".join(text) - options = _wizard_options("meta_key", "typeclass", color="|W") + options = _wizard_options("prototype", "meta_key", "typeclass", color="|W") return text, options @@ -963,7 +989,7 @@ def node_typeclass(caller): text.append("Using default typeclass {typeclass}.".format( typeclass=settings.BASE_OBJECT_TYPECLASS)) text = "\n\n".join(text) - options = _wizard_options("prototype", "key", color="|W") + options = _wizard_options("typeclass", "prototype", "key", color="|W") return text, options @@ -978,7 +1004,7 @@ def node_key(caller): else: text.append("Key is currently unset.") text = "\n\n".join(text) - options = _wizard_options("typeclass", "aliases") + options = _wizard_options("key", "typeclass", "aliases") options.append({"key": "_default", "goto": (_set_property, dict(prop="key", @@ -999,7 +1025,7 @@ def node_aliases(caller): else: text.append("No aliases are set.") text = "\n\n".join(text) - options = _wizard_options("key", "attrs") + options = _wizard_options("aliases", "key", "attrs") options.append({"key": "_default", "goto": (_set_property, dict(prop="aliases", @@ -1020,7 +1046,7 @@ def node_attrs(caller): else: text.append("No attrs are set.") text = "\n\n".join(text) - options = _wizard_options("aliases", "tags") + options = _wizard_options("attrs", "aliases", "tags") options.append({"key": "_default", "goto": (_set_property, dict(prop="attrs", @@ -1041,7 +1067,7 @@ def node_tags(caller): else: text.append("No tags are set.") text = "\n\n".join(text) - options = _wizard_options("attrs", "locks") + options = _wizard_options("tags", "attrs", "locks") options.append({"key": "_default", "goto": (_set_property, dict(prop="tags", @@ -1062,7 +1088,7 @@ def node_locks(caller): else: text.append("No locks are set.") text = "\n\n".join(text) - options = _wizard_options("tags", "permissions") + options = _wizard_options("locks", "tags", "permissions") options.append({"key": "_default", "goto": (_set_property, dict(prop="locks", @@ -1083,7 +1109,7 @@ def node_permissions(caller): else: text.append("No permissions are set.") text = "\n\n".join(text) - options = _wizard_options("destination", "location") + options = _wizard_options("permissions", "destination", "location") options.append({"key": "_default", "goto": (_set_property, dict(prop="permissions", @@ -1103,7 +1129,7 @@ def node_location(caller): else: text.append("Default location is {}'s inventory.".format(caller)) text = "\n\n".join(text) - options = _wizard_options("permissions", "home") + options = _wizard_options("location", "permissions", "home") options.append({"key": "_default", "goto": (_set_property, dict(prop="location", @@ -1123,7 +1149,7 @@ def node_home(caller): else: text.append("Default home location (|y{home}|n) used.".format(home=settings.DEFAULT_HOME)) text = "\n\n".join(text) - options = _wizard_options("aliases", "destination") + options = _wizard_options("home", "aliases", "destination") options.append({"key": "_default", "goto": (_set_property, dict(prop="home", @@ -1143,7 +1169,7 @@ def node_destination(caller): else: text.append("No destination is set (default).") text = "\n\n".join(text) - options = _wizard_options("home", "meta_desc") + options = _wizard_options("destination", "home", "meta_desc") options.append({"key": "_default", "goto": (_set_property, dict(prop="dest", @@ -1163,7 +1189,7 @@ def node_meta_desc(caller): else: text.append("Description is currently unset.") text = "\n\n".join(text) - options = _wizard_options("meta_key", "meta_tags") + options = _wizard_options("meta_desc", "meta_key", "meta_tags") options.append({"key": "_default", "goto": (_set_property, dict(prop='meta_desc', @@ -1184,7 +1210,7 @@ def node_meta_tags(caller): else: text.append("No tags are currently set.") text = "\n\n".join(text) - options = _wizard_options("meta_desc", "meta_locks") + options = _wizard_options("meta_tags", "meta_desc", "meta_locks") options.append({"key": "_default", "goto": (_set_property, dict(prop="meta_tags", @@ -1206,7 +1232,7 @@ def node_meta_locks(caller): text.append("Lock unset - if not changed the default lockstring will be set as\n" " |w'use:all(); edit:id({dbref}) or perm(Admin)'|n".format(dbref=caller.id)) text = "\n\n".join(text) - options = _wizard_options("meta_tags", "index") + options = _wizard_options("meta_locks", "meta_tags", "index") options.append({"key": "_default", "goto": (_set_property, dict(prop="meta_locks", @@ -1215,6 +1241,33 @@ def node_meta_locks(caller): return text, options +class OLCMenu(EvMenu): + """ + A custom EvMenu with a different formatting for the options. + + """ + def options_formatter(self, optionlist): + """ + Split the options into two blocks - olc options and normal options + + """ + olc_keys = ("index", "forward", "back", "previous", "next", "validate prototype") + olc_options = [] + other_options = [] + for key, desc in optionlist: + raw_key = strip_ansi(key) + if raw_key in olc_keys: + desc = " {}".format(desc) if desc else "" + olc_options.append("|lc{}|lt{}|le{}".format(raw_key, key, desc)) + else: + other_options.append((key, desc)) + + olc_options = " | ".join(olc_options) + " | " + "|wq|Wuit" if olc_options else "" + other_options = super(OLCMenu, self).options_formatter(other_options) + sep = "\n\n" if olc_options and other_options else "" + + return "{}{}{}".format(olc_options, sep, other_options) + def start_olc(caller, session=None, metaproto=None): """ @@ -1228,6 +1281,7 @@ def start_olc(caller, session=None, metaproto=None): """ menudata = {"node_index": node_index, + "node_validate_prototype": node_validate_prototype, "node_meta_key": node_meta_key, "node_prototype": node_prototype, "node_typeclass": node_typeclass, @@ -1244,7 +1298,7 @@ def start_olc(caller, session=None, metaproto=None): "node_meta_tags": node_meta_tags, "node_meta_locks": node_meta_locks, } - EvMenu(caller, menudata, startnode='node_index', session=session, olc_metaproto=metaproto) + OLCMenu(caller, menudata, startnode='node_index', session=session, olc_metaproto=metaproto) # Testing From 567243a3fc87ee929f98c95ab3bd77b44b493c2e Mon Sep 17 00:00:00 2001 From: Rachel Blackman Date: Sat, 31 Mar 2018 23:15:28 -0700 Subject: [PATCH 078/208] Update INFO command to take a single msg() call, and add better docstring. --- evennia/commands/default/unloggedin.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/evennia/commands/default/unloggedin.py b/evennia/commands/default/unloggedin.py index 28c126e6d8..293e022a19 100644 --- a/evennia/commands/default/unloggedin.py +++ b/evennia/commands/default/unloggedin.py @@ -521,18 +521,19 @@ class CmdUnconnectedScreenreader(COMMAND_DEFAULT_CLASS): class CmdUnconnectedInfo(COMMAND_DEFAULT_CLASS): """ Provides MUDINFO output, so that Evennia games can be added to Mudconnector - and Mudstats. + and Mudstats. Sadly, the MUDINFO specification seems to have dropped off the + face of the net, but it is still used by some crawlers. This implementation + was created by looking at the MUDINFO implementation in MUX2, TinyMUSH, Rhost, + and PennMUSH. """ key = "info" locks = "cmd:all()" def func(self): - self.caller.msg("## BEGIN INFO 1.1") - self.caller.msg("Name: %s" % settings.SERVERNAME) - self.caller.msg("Uptime: %s" % datetime.datetime.fromtimestamp(gametime.SERVER_START_TIME).ctime()) - self.caller.msg("Connected: %d" % SESSIONS.account_count()) - self.caller.msg("Version: Evennia %s" % utils.get_evennia_version()) - self.caller.msg("## END INFO") + self.caller.msg("## BEGIN INFO 1.1\nName: %s\nUptime: %s\nConnected: %d\nVersion: Evennia %s\n## END INFO" % ( + settings.SERVERNAME, + datetime.datetime.fromtimestamp(gametime.SERVER_START_TIME).ctime(), + SESSIONS.account_count(), utils.get_evennia_version())) def _create_account(session, accountname, password, permissions, typeclass=None, email=None): From 43cec126ed6755bf6a9bdb7cc1ccf8c70212ccc6 Mon Sep 17 00:00:00 2001 From: Griatch Date: Sun, 1 Apr 2018 08:29:35 +0200 Subject: [PATCH 079/208] Better handle logfile cycle while tailing --- evennia/server/evennia_launcher.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/evennia/server/evennia_launcher.py b/evennia/server/evennia_launcher.py index c82b922ca0..2f9206b4c1 100644 --- a/evennia/server/evennia_launcher.py +++ b/evennia/server/evennia_launcher.py @@ -1037,9 +1037,11 @@ def tail_log_files(filename1, filename2, start_lines1=20, start_lines2=20, rate= new_linecount = sum(blck.count("\n") for blck in _block(filehandle)) if new_linecount < old_linecount: - # this could happen if the file was manually deleted or edited - print("Log file has shrunk. Restart log reader.") - sys.exit() + # this happens if the file was cycled or manually deleted/edited. + print(" ** Log file {filename} has cycled or been edited. " + "Restarting log. ".format(filehandle.name)) + new_linecount = 0 + old_linecount = 0 lines_to_get = max(0, new_linecount - old_linecount) From 9e46d996b155ebdb045c7f8106aaa3d4a4ed3b97 Mon Sep 17 00:00:00 2001 From: Griatch Date: Sun, 1 Apr 2018 09:26:55 +0200 Subject: [PATCH 080/208] Fix olc with existing prototype --- evennia/commands/default/building.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/evennia/commands/default/building.py b/evennia/commands/default/building.py index 759247acc7..77bdb619f1 100644 --- a/evennia/commands/default/building.py +++ b/evennia/commands/default/building.py @@ -2909,7 +2909,7 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): elif metaprot: # one match metaprot = metaprot[0] - start_olc(caller, self.session, metaprot) + start_olc(caller, session=self.session, metaproto=metaprot) return if 'search' in self.switches: From 34d63e9d23be8259da61837efb28207c3cfb66ba Mon Sep 17 00:00:00 2001 From: Griatch Date: Sun, 1 Apr 2018 09:29:44 +0200 Subject: [PATCH 081/208] fix olc bug with single prototype --- evennia/utils/spawner.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/evennia/utils/spawner.py b/evennia/utils/spawner.py index c9a68ea8ab..bab8cdc9a1 100644 --- a/evennia/utils/spawner.py +++ b/evennia/utils/spawner.py @@ -765,6 +765,9 @@ def _set_property(caller, raw_string, **kwargs): else: value = raw_string + if not value: + return next_node + if meta: _set_menu_metaprot(caller, propname_low, value) else: @@ -780,7 +783,7 @@ def _set_property(caller, raw_string, **kwargs): _set_menu_metaprot(caller, "prototype", prototype) - caller.msg("Set {prop} to {value}.".format( + caller.msg("Set {prop} to '{value}'.".format( prop=prop.replace("_", "-").capitalize(), value=str(value))) return next_node @@ -874,7 +877,7 @@ def node_validate_prototype(caller, raw_string, **kwargs): def _check_meta_key(caller, key): old_metaprot = search_prototype(key) - olc_new = caller.ndb._menutree.olc_new + olc_new = _is_new_prototype(caller) key = key.strip().lower() if old_metaprot: # we are starting a new prototype that matches an existing @@ -1298,7 +1301,7 @@ def start_olc(caller, session=None, metaproto=None): "node_meta_tags": node_meta_tags, "node_meta_locks": node_meta_locks, } - OLCMenu(caller, menudata, startnode='node_index', session=session, olc_metaproto=metaproto) + OLCMenu(caller, menudata, startnode='node_index', session=session, olc_metaprot=metaproto) # Testing From 2ab99ee1bf97f2f4bc715340f0bd6478e7677175 Mon Sep 17 00:00:00 2001 From: Rachel Blackman Date: Sun, 1 Apr 2018 00:39:16 -0700 Subject: [PATCH 082/208] Add test for unconnected commands, add INFO command to test set. --- evennia/commands/default/tests.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/evennia/commands/default/tests.py b/evennia/commands/default/tests.py index 41654077a4..a645014482 100644 --- a/evennia/commands/default/tests.py +++ b/evennia/commands/default/tests.py @@ -14,15 +14,16 @@ main test suite started with import re import types +import datetime from django.conf import settings from mock import Mock, mock from evennia.commands.default.cmdset_character import CharacterCmdSet from evennia.utils.test_resources import EvenniaTest -from evennia.commands.default import help, general, system, admin, account, building, batchprocess, comms +from evennia.commands.default import help, general, system, admin, account, building, batchprocess, comms, unloggedin from evennia.commands.command import Command, InterruptCommand -from evennia.utils import ansi, utils +from evennia.utils import ansi, utils, gametime from evennia.server.sessionhandler import SESSIONS from evennia import search_object from evennia import DefaultObject, DefaultCharacter @@ -433,3 +434,12 @@ class TestInterruptCommand(CommandTest): def test_interrupt_command(self): ret = self.call(CmdInterrupt(), "") self.assertEqual(ret, "") + + +class TestUnconnectedCommand(CommandTest): + def test_info_command(self): + expected = "## BEGIN INFO 1.1\nName: %s\nUptime: %s\nConnected: %d\nVersion: Evennia %s\n## END INFO" % ( + settings.SERVERNAME, + datetime.datetime.fromtimestamp(gametime.SERVER_START_TIME).ctime(), + SESSIONS.account_count(), utils.get_evennia_version()) + self.call(unloggedin.CmdUnconnectedInfo(), "", expected) \ No newline at end of file From 0e9d6f9c0582ada7dbc935735a0d2e16c55e1d96 Mon Sep 17 00:00:00 2001 From: Griatch Date: Mon, 2 Apr 2018 18:46:55 +0200 Subject: [PATCH 083/208] Start add edit_node decorator (untested) --- evennia/utils/evmenu.py | 427 +++++++++++++++++++++++++++++---------- evennia/utils/spawner.py | 10 +- 2 files changed, 331 insertions(+), 106 deletions(-) diff --git a/evennia/utils/evmenu.py b/evennia/utils/evmenu.py index cccda6798f..e9e3f1c8af 100644 --- a/evennia/utils/evmenu.py +++ b/evennia/utils/evmenu.py @@ -175,7 +175,7 @@ from evennia import Command, CmdSet from evennia.utils import logger from evennia.utils.evtable import EvTable from evennia.utils.ansi import strip_ansi -from evennia.utils.utils import mod_import, make_iter, pad, m_len +from evennia.utils.utils import mod_import, make_iter, pad, m_len, is_iter from evennia.commands import cmdhandler # read from protocol NAWS later? @@ -683,6 +683,43 @@ class EvMenu(object): return ret, kwargs return None + def extract_goto_exec(self, nodename, option_dict): + """ + Helper: Get callables and their eventual kwargs. + + Args: + nodename (str): The current node name (used for error reporting). + option_dict (dict): The seleted option's dict. + + Returns: + goto (str, callable or None): The goto directive in the option. + goto_kwargs (dict): Kwargs for `goto` if the former is callable, otherwise empty. + execute (callable or None): Executable given by the `exec` directive. + exec_kwargs (dict): Kwargs for `execute` if it's callable, otherwise empty. + + """ + goto_kwargs, exec_kwargs = {}, {} + goto, execute = option_dict.get("goto", None), option_dict.get("exec", None) + if goto and isinstance(goto, (tuple, list)): + if len(goto) > 1: + goto, goto_kwargs = goto[:2] # ignore any extra arguments + if not hasattr(goto_kwargs, "__getitem__"): + # not a dict-like structure + raise EvMenuError("EvMenu node {}: goto kwargs is not a dict: {}".format( + nodename, goto_kwargs)) + else: + goto = goto[0] + if execute and isinstance(execute, (tuple, list)): + if len(execute) > 1: + execute, exec_kwargs = execute[:2] # ignore any extra arguments + if not hasattr(exec_kwargs, "__getitem__"): + # not a dict-like structure + raise EvMenuError("EvMenu node {}: exec kwargs is not a dict: {}".format( + nodename, goto_kwargs)) + else: + execute = execute[0] + return goto, goto_kwargs, execute, exec_kwargs + def goto(self, nodename, raw_string, **kwargs): """ Run a node by name, optionally dynamically generating that name first. @@ -696,29 +733,6 @@ class EvMenu(object): argument) """ - def _extract_goto_exec(option_dict): - "Helper: Get callables and their eventual kwargs" - goto_kwargs, exec_kwargs = {}, {} - goto, execute = option_dict.get("goto", None), option_dict.get("exec", None) - if goto and isinstance(goto, (tuple, list)): - if len(goto) > 1: - goto, goto_kwargs = goto[:2] # ignore any extra arguments - if not hasattr(goto_kwargs, "__getitem__"): - # not a dict-like structure - raise EvMenuError("EvMenu node {}: goto kwargs is not a dict: {}".format( - nodename, goto_kwargs)) - else: - goto = goto[0] - if execute and isinstance(execute, (tuple, list)): - if len(execute) > 1: - execute, exec_kwargs = execute[:2] # ignore any extra arguments - if not hasattr(exec_kwargs, "__getitem__"): - # not a dict-like structure - raise EvMenuError("EvMenu node {}: exec kwargs is not a dict: {}".format( - nodename, goto_kwargs)) - else: - execute = execute[0] - return goto, goto_kwargs, execute, exec_kwargs if callable(nodename): # run the "goto" callable, if possible @@ -764,12 +778,12 @@ class EvMenu(object): desc = dic.get("desc", dic.get("text", None)) if "_default" in keys: keys = [key for key in keys if key != "_default"] - goto, goto_kwargs, execute, exec_kwargs = _extract_goto_exec(dic) + goto, goto_kwargs, execute, exec_kwargs = self.extract_goto_exec(nodename, dic) self.default = (goto, goto_kwargs, execute, exec_kwargs) else: # use the key (only) if set, otherwise use the running number keys = list(make_iter(dic.get("key", str(inum + 1).strip()))) - goto, goto_kwargs, execute, exec_kwargs = _extract_goto_exec(dic) + goto, goto_kwargs, execute, exec_kwargs = self.extract_goto_exec(nodename, dic) if keys: display_options.append((keys[0], desc)) for key in keys: @@ -975,11 +989,143 @@ class EvMenu(object): # ----------------------------------------------------------- # -# List node +# Edit node (decorator turning a node into an editing +# point for a given resource # # ----------------------------------------------------------- -def list_node(option_generator, examine_processor, goto_processor, pagesize=10): +def edit_node(edit_text, add_text, edit_callback, add_callback, get_choices=None): + """ + Decorator for turning an EvMenu node into an editing + page. Will add new options, prepending those options + added in the node. + + Args: + edit_text (str or callable): Will be used as text for the edit node. If + callable, it will be called as edittext(selection) + and should return the node text for the edit-node, probably listing + the current value of all editable propnames, if possible. + add_text (str) or callable: Gives text for node in add-mode. If a callable, + called as add_text() and should return the text for the node. + edit_callback (callable): Will be called as edit_callback(editable, raw_string) + and should return a boolean True/False if the setting of the property + succeeded or not. The value will always be a string and should be + converted as needed. + add_callback (callable): Will be called as add_callback(raw_string) and + should return a boolean True/False if the addition succeded. + + get_choices (callable): Produce the available editable choices. If this + is not given, the `goto` callable must have been provided with the + kwarg `available_choices` by the decorated node. + + """ + + def decorator(func): + + def _setter_goto(caller, raw_string, **kwargs): + editable = kwargs.get("editable") + mode = kwargs.get("edit_node_mode") + try: + if mode == 'edit': + is_ok = edit_callback(editable, raw_string) + else: + is_ok = add_callback(raw_string) + except Exception: + logger.log_trace() + if not is_ok: + caller.msg("|rValue could not be set.") + return None + + def _patch_goto(caller, raw_string, **kwargs): + + # parse incoming string to figure out if there is a match to edit/add + match = re.search(r"(^[a-zA-Z]*)\s*([0-9]*)$", raw_string) + cmd, number = match.groups() + edit_mode = None + available_choices = None + selection = None + + if get_choices: + available_choices = make_iter(get_choices(caller, raw_string, **kwargs)) + if not available_choices: + available_choices = kwargs.get("available_choices", []) + + if available_choices and cmd.startswith("e"): + try: + index = int(cmd) - 1 + selection = available_choices[index] + edit_mode = 'edit' + except (IndexError, TypeError): + caller.msg("|rNot a valid 'edit' command.") + + if cmd.startswith("a") and not number: + # add mode + edit_mode = "add" + + if edit_mode: + # replace with edit text/options + text = edit_text(selection) if edit_mode == "edit" else add_text() + options = ({"key": "_default", + "goto": (_setter_goto, + {"selection": selection, + "edit_node_mode": edit_mode})}) + return text, options + + # no matches - pass through to the original decorated goto instruction + + decorated_opt = kwargs.get("decorated_opt") + + if decorated_opt: + # use EvMenu's parser to get the goto/goto-kwargs out of + # the decorated option structure + dec_goto, dec_goto_kwargs, _, _ = \ + caller.ndb._menutree.extract_goto_exec("edit-node", decorated_opt) + + if callable(dec_goto): + try: + return dec_goto(caller, raw_string, + **{dec_goto_kwargs if dec_goto_kwargs else {}}) + except Exception: + caller.msg("|rThere was an error in the edit node.") + logger.log_trace() + return None + + def _edit_node(caller, raw_string, **kwargs): + + text, options = func(caller, raw_string, **kwargs) + + if options: + # find eventual _default in options and patch it with a handler for + # catching editing + + decorated_opt = None + iopt = 0 + for iopt, optdict in enumerate(options): + if optdict.get('key') == "_default": + decorated_opt = optdict + break + + if decorated_opt: + # inject our wrapper over the original goto instruction for the + # _default action (save the original) + options[iopt]["goto"] = (_patch_goto, + {"decorated_opt": decorated_opt}) + + return text, options + + return _edit_node + return decorator + + + +# ----------------------------------------------------------- +# +# List node (decorator turning a node into a list with +# look/edit/add functionality for the elements) +# +# ----------------------------------------------------------- + +def list_node(option_generator, select=None, examine=None, edit=None, add=None, pagesize=10): """ Decorator for making an EvMenu node into a multi-page list node. Will add new options, prepending those options added in the node. @@ -987,17 +1133,25 @@ def list_node(option_generator, examine_processor, goto_processor, pagesize=10): Args: option_generator (callable or list): A list of strings indicating the options, or a callable that is called without any arguments to produce such a list. - examine_processor (callable, optional): Will be called with the caller and the chosen option - when examining said option. Should return a text string to display in the node. - goto_processor (callable, optional): Will be called as goto_processor(caller, menuchoice) + select (callable, option): Will be called as select(caller, menuchoice) where menuchoice is the chosen option as a string. Should return the target node to - goto after this selection. + goto after this selection. Note that if this is not given, the decorated node must itself + provide a way to continue from the node! + examine (callable, optional): If given, allows for examining options in detail. Will + be called with examine(caller, menuchoice) and should return a text string to + display in-place in the node. + edit (callable, optional): If given, this callable will be called as edit(caller, menuchoice). + It should return the node-key to a node decorated with the `edit_node` decorator. The + menuchoice will automatically be stored on the menutree as `list_node_edit`. + add (tuple, optional): If given, this callable will be called as add(caller, menuchoice). + It should return the node-key to a node decorated with the `edit_node` decorator. The + menuchoice will automatically be stored on the menutree as `list_node_add`. pagesize (int): How many options to show per page. Example: - @list_node(['foo', 'bar'], examine_processor, goto_processor) + @list_node(['foo', 'bar'], examine, select) def node_index(caller): text = "describing the list" return text, [] @@ -1006,27 +1160,63 @@ def list_node(option_generator, examine_processor, goto_processor, pagesize=10): def decorator(func): - def _input_parser(caller, raw_string, **kwargs): - "Parse which input was given, select from option_list" - + def _select_parser(caller, raw_string, **kwargs): + """ + Parse the select action + """ available_choices = kwargs.get("available_choices", []) - processor = kwargs.get("selection_processor") try: - match_ind = int(re.search(r"[0-9]+$", raw_string).group()) - 1 - selection = available_choices[match_ind] - except (AttributeError, KeyError, IndexError, ValueError): - return None - - if processor: + index = int(raw_string.strip()) - 1 + selection = available_choices[index] + except Exception: + caller.msg("|rInvalid choice.|n") + else: try: - return processor(caller, selection) + return select(caller, selection) except Exception: logger.log_trace() - return selection + return None + + def _input_parser(caller, raw_string, **kwargs): + """ + Parse which input was given, select from option_list. + + Understood input is [cmd], where [cmd] is either empty (`select`) + or one of the supported actions `look`, `edit` or `add` depending on + which processors are available. + + """ + + available_choices = kwargs.get("available_choices", []) + match = re.search(r"(^[a-zA-Z]*)\s*([0-9]*)$", raw_string) + cmd, number = match.groups() + mode, selection = None, None + + if number: + number = int(number) - 1 + cmd = cmd.lower().strip() + if cmd.startswith("e") or cmd.startswith("a") and edit: + mode = "edit" + elif examine: + mode = "examine" + + try: + selection = available_choices[number] + except IndexError: + caller.msg("|rInvalid index") + mode = None + else: + caller.msg("|rMust supply a number.") + + return mode, selection + + def _relay_to_edit_or_add(caller, raw_string, **kwargs): + pass def _list_node(caller, raw_string, **kwargs): + mode = kwargs.get("list_mode", None) option_list = option_generator() if callable(option_generator) else option_generator nall_options = len(option_list) @@ -1035,71 +1225,104 @@ def list_node(option_generator, examine_processor, goto_processor, pagesize=10): page_index = max(0, min(npages - 1, kwargs.get("optionpage_index", 0))) page = pages[page_index] + entry = None + extra_text = None - # dynamic, multi-page option list. We use _input_parser as a goto-callable, - # with the `goto_processor` redirecting when we leave the node. - options = [{"desc": opt, - "goto": (_input_parser, - {"available_choices": page, - "selection_processor": goto_processor})} for opt in page] + if mode == "arbitrary": + # freeform input, we must parse it for the allowed commands (look/edit) + mode, entry = _input_parser(caller, raw_string, + **{"available_choices": page}) - if npages > 1: - # if the goto callable returns None, the same node is rerun, and - # kwargs not used by the callable are passed on to the node. This - # allows us to call ourselves over and over, using different kwargs. - options.append({"key": ("|Wcurrent|n", "c"), - "desc": "|W({}/{})|n".format(page_index + 1, npages), - "goto": (lambda caller: None, - {"optionpage_index": page_index})}) - if page_index > 0: - options.append({"key": ("|wp|Wrevious page|n", "p"), - "goto": (lambda caller: None, - {"optionpage_index": page_index - 1})}) - if page_index < npages - 1: - options.append({"key": ("|wn|Wext page|n", "n"), - "goto": (lambda caller: None, - {"optionpage_index": page_index + 1})}) - - - # this catches arbitrary input, notably to examine entries ('look 4' or 'l4' etc) - options.append({"key": "_default", - "goto": (lambda caller: None, - {"show_detail": True, "optionpage_index": page_index})}) - - # update text with detail, if set. Here we call _input_parser like a normal function - text_detail = None - if raw_string and 'show_detail' in kwargs: - text_detail = _input_parser( - caller, raw_string, **{"available_choices": page, - "selection_processor": examine_processor}) - if text_detail is None: - text_detail = "|rThat's not a valid command or option.|n" - - # add data from the decorated node - - text = '' - extra_options = [] - try: - text, extra_options = func(caller, raw_string) - except TypeError: + if examine and mode: # == "look": + # look mode - we are examining a given entry try: - text, extra_options = func(caller) + text = examine(caller, entry) except Exception: - raise - except Exception: - logger.log_trace() - print("extra_options:", extra_options) + logger.log_trace() + text = "|rCould not view." + options = [{"key": ("|wb|Wack|n", "b"), + "goto": (lambda caller: None, + {"optionpage_index": page_index})}, + {"key": "_default", + "goto": (lambda caller: None, + {"optionpage_index": page_index})}] + return text, options + + # if edit and mode == "edit": + # pass + # elif add and mode == "add": + # # add mode - we are adding a new entry + # pass + else: - if isinstance(extra_options, {}): - extra_options = [extra_options] + # normal mode - list + pass + + if select: + # We have a processor to handle selecting an entry + + # dynamic, multi-page option list. Each selection leads to the `select` + # callback being called with a result from the available choices + options = [{"desc": opt, + "goto": (_select_parser, + {"available_choices": page})} for opt in page] + + if add: + # We have a processor to handle adding a new entry. Re-run this node + # in the 'add' mode + options.append({"key": ("|wadd|Wdd new|n", "a"), + "goto": (lambda caller: None, + {"optionpage_index": page_index, + "list_mode": "add"})}) + if npages > 1: + # if the goto callable returns None, the same node is rerun, and + # kwargs not used by the callable are passed on to the node. This + # allows us to call ourselves over and over, using different kwargs. + options.append({"key": ("|Wcurrent|n", "c"), + "desc": "|W({}/{})|n".format(page_index + 1, npages), + "goto": (lambda caller: None, + {"optionpage_index": page_index})}) + if page_index > 0: + options.append({"key": ("|wp|Wrevious page|n", "p"), + "goto": (lambda caller: None, + {"optionpage_index": page_index - 1})}) + if page_index < npages - 1: + options.append({"key": ("|wn|Wext page|n", "n"), + "goto": (lambda caller: None, + {"optionpage_index": page_index + 1})}) + + # this catches arbitrary input and reruns this node with the 'arbitrary' mode + # this could mean input on the form 'look ' or 'edit ' + options.append({"key": "_default", + "goto": (lambda caller: None, + {"optionpage_index": page_index, + "available_choices": page, + "list_mode": "arbitrary"})}) + + # add data from the decorated node + + extra_options = [] + try: + text, extra_options = func(caller, raw_string) + except TypeError: + try: + text, extra_options = func(caller) + except Exception: + raise + except Exception: + logger.log_trace() + print("extra_options:", extra_options) else: - extra_options = make_iter(extra_options) + if isinstance(extra_options, {}): + extra_options = [extra_options] + else: + extra_options = make_iter(extra_options) - options.extend(extra_options) - text = text + "\n\n" + text_detail if text_detail else text - text += "\n\n(Make a choice or enter 'look ' to examine an option closer)" + options.extend(extra_options) + text = text + "\n\n" + extra_text if extra_text else text + text += "\n\n(Make a choice or enter 'look ' to examine an option closer)" - return text, options + return text, options return _list_node return decorator diff --git a/evennia/utils/spawner.py b/evennia/utils/spawner.py index bab8cdc9a1..eaab84b202 100644 --- a/evennia/utils/spawner.py +++ b/evennia/utils/spawner.py @@ -690,9 +690,11 @@ def spawn(*prototypes, **kwargs): # Helper functions def _get_menu_metaprot(caller): + + metaproto = None if hasattr(caller.ndb._menutree, "olc_metaprot"): - return caller.ndb._menutree.olc_metaprot - else: + metaproto = caller.ndb._menutree.olc_metaprot + if not metaproto: metaproto = build_metaproto(None, '', [], [], None) caller.ndb._menutree.olc_metaprot = metaproto caller.ndb._menutree.olc_new = True @@ -931,7 +933,7 @@ def _prototype_select(caller, prototype): return ret -@list_node(_all_prototypes, _prototype_examine, _prototype_select) +@list_node(_all_prototypes, _prototype_select, examine=_prototype_examine) def node_prototype(caller): metaprot = _get_menu_metaprot(caller) prot = metaprot.prototype @@ -979,7 +981,7 @@ def _typeclass_select(caller, typeclass): return ret -@list_node(_all_typeclasses, _typeclass_examine, _typeclass_select) +@list_node(_all_typeclasses, _typeclass_select, examine=_typeclass_examine) def node_typeclass(caller): metaprot = _get_menu_metaprot(caller) prot = metaprot.prototype From 8777d6311e56652f30aa586fba601daf93fc00f9 Mon Sep 17 00:00:00 2001 From: Griatch Date: Mon, 2 Apr 2018 19:55:31 +0200 Subject: [PATCH 084/208] Partial edit_node functionality --- evennia/utils/evmenu.py | 42 ++++++++++++++++++++++++---------------- evennia/utils/spawner.py | 24 +++++++++-------------- 2 files changed, 34 insertions(+), 32 deletions(-) diff --git a/evennia/utils/evmenu.py b/evennia/utils/evmenu.py index e9e3f1c8af..6df9142bbb 100644 --- a/evennia/utils/evmenu.py +++ b/evennia/utils/evmenu.py @@ -1132,7 +1132,7 @@ def list_node(option_generator, select=None, examine=None, edit=None, add=None, Args: option_generator (callable or list): A list of strings indicating the options, or a callable - that is called without any arguments to produce such a list. + that is called as option_generator(caller) to produce such a list. select (callable, option): Will be called as select(caller, menuchoice) where menuchoice is the chosen option as a string. Should return the target node to goto after this selection. Note that if this is not given, the decorated node must itself @@ -1217,14 +1217,22 @@ def list_node(option_generator, select=None, examine=None, edit=None, add=None, def _list_node(caller, raw_string, **kwargs): mode = kwargs.get("list_mode", None) - option_list = option_generator() if callable(option_generator) else option_generator + option_list = option_generator(caller) if callable(option_generator) else option_generator - nall_options = len(option_list) - pages = [option_list[ind:ind + pagesize] for ind in range(0, nall_options, pagesize)] - npages = len(pages) + npages = 0 + page_index = 0 + page = None + options = [] - page_index = max(0, min(npages - 1, kwargs.get("optionpage_index", 0))) - page = pages[page_index] + if option_list: + nall_options = len(option_list) + pages = [option_list[ind:ind + pagesize] for ind in range(0, nall_options, pagesize)] + npages = len(pages) + + page_index = max(0, min(npages - 1, kwargs.get("optionpage_index", 0))) + page = pages[page_index] + + text = "" entry = None extra_text = None @@ -1233,19 +1241,19 @@ def list_node(option_generator, select=None, examine=None, edit=None, add=None, mode, entry = _input_parser(caller, raw_string, **{"available_choices": page}) - if examine and mode: # == "look": + if examine and mode: # == "look": # look mode - we are examining a given entry try: text = examine(caller, entry) except Exception: logger.log_trace() text = "|rCould not view." - options = [{"key": ("|wb|Wack|n", "b"), - "goto": (lambda caller: None, - {"optionpage_index": page_index})}, - {"key": "_default", - "goto": (lambda caller: None, - {"optionpage_index": page_index})}] + options.extend([{"key": ("|wb|Wack|n", "b"), + "goto": (lambda caller: None, + {"optionpage_index": page_index})}, + {"key": "_default", + "goto": (lambda caller: None, + {"optionpage_index": page_index})}]) return text, options # if edit and mode == "edit": @@ -1263,9 +1271,9 @@ def list_node(option_generator, select=None, examine=None, edit=None, add=None, # dynamic, multi-page option list. Each selection leads to the `select` # callback being called with a result from the available choices - options = [{"desc": opt, - "goto": (_select_parser, - {"available_choices": page})} for opt in page] + options.extend([{"desc": opt, + "goto": (_select_parser, + {"available_choices": page})} for opt in page]) if add: # We have a processor to handle adding a new entry. Re-run this node diff --git a/evennia/utils/spawner.py b/evennia/utils/spawner.py index eaab84b202..ebf3ce5d67 100644 --- a/evennia/utils/spawner.py +++ b/evennia/utils/spawner.py @@ -916,7 +916,7 @@ def node_meta_key(caller): return text, options -def _all_prototypes(): +def _all_prototypes(caller): return [mproto.key for mproto in search_prototype()] @@ -949,7 +949,7 @@ def node_prototype(caller): return text, options -def _all_typeclasses(): +def _all_typeclasses(caller): return list(sorted(get_all_typeclasses().keys())) # return list(sorted(get_all_typeclasses(parent="evennia.objects.objects.DefaultObject").keys())) @@ -1060,24 +1060,17 @@ def node_attrs(caller): return text, options -def node_tags(caller): +def _caller_tags(caller): metaprot = _get_menu_metaprot(caller) prot = metaprot.prototype tags = prot.get("tags") + return tags - text = ["Set the prototype's |yTags|n. Separate multiple tags with commas. " - "Will retain case sensitivity."] - if tags: - text.append("Current tags are '|y{tags}|n'.".format(tags=tags)) - else: - text.append("No tags are set.") - text = "\n\n".join(text) + +@list_node(_caller_tags) +def node_tags(caller): + text = "Set the prototype's |yTags|n." options = _wizard_options("tags", "attrs", "locks") - options.append({"key": "_default", - "goto": (_set_property, - dict(prop="tags", - processor=lambda s: [part.strip() for part in s.split(",")], - next_node="node_locks"))}) return text, options @@ -1204,6 +1197,7 @@ def node_meta_desc(caller): return text, options + def node_meta_tags(caller): metaprot = _get_menu_metaprot(caller) text = ["|wMeta-Tags|n can be used to classify and find prototypes. Tags are case-insensitive. " From cb170ef89a97dd88c909d895ac11336e46fa2092 Mon Sep 17 00:00:00 2001 From: Griatch Date: Mon, 2 Apr 2018 23:35:35 +0200 Subject: [PATCH 085/208] Progress on expanding list_node with edit/add instead --- evennia/utils/evmenu.py | 88 +++++++++++++++++++++------------------- evennia/utils/spawner.py | 24 ++++++++++- 2 files changed, 69 insertions(+), 43 deletions(-) diff --git a/evennia/utils/evmenu.py b/evennia/utils/evmenu.py index 6df9142bbb..c9f31b4688 100644 --- a/evennia/utils/evmenu.py +++ b/evennia/utils/evmenu.py @@ -1135,17 +1135,15 @@ def list_node(option_generator, select=None, examine=None, edit=None, add=None, that is called as option_generator(caller) to produce such a list. select (callable, option): Will be called as select(caller, menuchoice) where menuchoice is the chosen option as a string. Should return the target node to - goto after this selection. Note that if this is not given, the decorated node must itself - provide a way to continue from the node! + goto after this selection. Note that if this is not given, the decorated node must + itself provide a way to continue from the node! examine (callable, optional): If given, allows for examining options in detail. Will be called with examine(caller, menuchoice) and should return a text string to display in-place in the node. - edit (callable, optional): If given, this callable will be called as edit(caller, menuchoice). - It should return the node-key to a node decorated with the `edit_node` decorator. The - menuchoice will automatically be stored on the menutree as `list_node_edit`. - add (tuple, optional): If given, this callable will be called as add(caller, menuchoice). - It should return the node-key to a node decorated with the `edit_node` decorator. The - menuchoice will automatically be stored on the menutree as `list_node_add`. + edit (callable, optional): If given, this callable will be called as edit(caller, + menuchoice, **kwargs) and should return a complete (text, options) tuple (like a node). + add (callable optional): If given, this callable will be called as add(caller, menuchoice, + **kwargs) and should return a complete (text, options) tuple (like a node). pagesize (int): How many options to show per page. @@ -1189,25 +1187,28 @@ def list_node(option_generator, select=None, examine=None, edit=None, add=None, """ available_choices = kwargs.get("available_choices", []) - match = re.search(r"(^[a-zA-Z]*)\s*([0-9]*)$", raw_string) - cmd, number = match.groups() + match = re.search(r"(^[a-zA-Z]*)\s*(.*?)$", raw_string) + cmd, args = match.groups() mode, selection = None, None + cmd = cmd.lower().strip() - if number: - number = int(number) - 1 - cmd = cmd.lower().strip() - if cmd.startswith("e") or cmd.startswith("a") and edit: - mode = "edit" - elif examine: - mode = "examine" - + if args: try: - selection = available_choices[number] - except IndexError: - caller.msg("|rInvalid index") - mode = None - else: - caller.msg("|rMust supply a number.") + number = int(args) - 1 + except ValueError: + if cmd.startswith("a") and add: + mode = "add" + selection = args + else: + if cmd.startswith("e") and edit: + mode = "edit" + elif examine: + mode = "look" + try: + selection = available_choices[number] + except IndexError: + caller.msg("|rInvalid index") + mode = None return mode, selection @@ -1233,18 +1234,18 @@ def list_node(option_generator, select=None, examine=None, edit=None, add=None, page = pages[page_index] text = "" - entry = None + selection = None extra_text = None if mode == "arbitrary": # freeform input, we must parse it for the allowed commands (look/edit) - mode, entry = _input_parser(caller, raw_string, - **{"available_choices": page}) + mode, selection = _input_parser(caller, raw_string, + **{"available_choices": page}) - if examine and mode: # == "look": + if examine and mode == "look": # look mode - we are examining a given entry try: - text = examine(caller, entry) + text = examine(caller, selection) except Exception: logger.log_trace() text = "|rCould not view." @@ -1256,15 +1257,25 @@ def list_node(option_generator, select=None, examine=None, edit=None, add=None, {"optionpage_index": page_index})}]) return text, options - # if edit and mode == "edit": - # pass - # elif add and mode == "add": - # # add mode - we are adding a new entry - # pass + elif add and mode == 'add': + # add mode - the selection is the new value + try: + text, options = add(caller, selection, **kwargs) + except Exception: + logger.log_trace() + text = "|rCould not add." + return text, options + + elif edit and mode == 'edit': + try: + text, options = edit(caller, selection, **kwargs) + except Exception: + logger.log_trace() + text = "|Could not edit." + return text, options else: # normal mode - list - pass if select: # We have a processor to handle selecting an entry @@ -1275,13 +1286,6 @@ def list_node(option_generator, select=None, examine=None, edit=None, add=None, "goto": (_select_parser, {"available_choices": page})} for opt in page]) - if add: - # We have a processor to handle adding a new entry. Re-run this node - # in the 'add' mode - options.append({"key": ("|wadd|Wdd new|n", "a"), - "goto": (lambda caller: None, - {"optionpage_index": page_index, - "list_mode": "add"})}) if npages > 1: # if the goto callable returns None, the same node is rerun, and # kwargs not used by the callable are passed on to the node. This diff --git a/evennia/utils/spawner.py b/evennia/utils/spawner.py index ebf3ce5d67..2fbdbaed21 100644 --- a/evennia/utils/spawner.py +++ b/evennia/utils/spawner.py @@ -1066,8 +1066,30 @@ def _caller_tags(caller): tags = prot.get("tags") return tags +def _add_tags(caller, tag, **kwargs): + tag = tag.strip().lower() + metaprot = _get_menu_metaprot(caller) + tags = metaprot.tags + if tags: + if tag not in tags: + tags.append(tag) + else: + tags = [tag] + metaprot.tags = tags + text = kwargs.get("text") + if not text: + text = "Added tag {}. (return to continue)".format(tag) + options = {"key": "_default", + "goto": lambda caller: None} + return text, options -@list_node(_caller_tags) + +def _edit_tag(caller, tag, **kwargs): + tag = tag.strip().lower() + metaprot = _get_menu_metaprot(caller) + #TODO change in evmenu so one can do e 3 right away, parse & store value in kwarg + +@list_node(_caller_tags, edit=_edit_tags) def node_tags(caller): text = "Set the prototype's |yTags|n." options = _wizard_options("tags", "attrs", "locks") From 86a1f395257a58440783e46124f9340a678550eb Mon Sep 17 00:00:00 2001 From: Griatch Date: Sun, 8 Apr 2018 13:21:51 +0200 Subject: [PATCH 086/208] Unworking commit for stashing --- evennia/utils/evmenu.py | 32 +++++++++++++++++--------------- evennia/utils/spawner.py | 1 - 2 files changed, 17 insertions(+), 16 deletions(-) diff --git a/evennia/utils/evmenu.py b/evennia/utils/evmenu.py index c9f31b4688..2cb282a641 100644 --- a/evennia/utils/evmenu.py +++ b/evennia/utils/evmenu.py @@ -1185,32 +1185,34 @@ def list_node(option_generator, select=None, examine=None, edit=None, add=None, which processors are available. """ - + mode, selection, new_value = None, None, None available_choices = kwargs.get("available_choices", []) - match = re.search(r"(^[a-zA-Z]*)\s*(.*?)$", raw_string) - cmd, args = match.groups() - mode, selection = None, None - cmd = cmd.lower().strip() - if args: + cmd, args = re.search(r"(^[a-zA-Z]*)\s*(.*?)$", raw_string).groups() + + cmd = cmd.lower().strip() + if cmd.startswith('a') and add: + mode = "add" + new_value = args + else: + selection, new_value = re.search(r"(^[0-9]*)\s*(.*?)$", args).groups() try: - number = int(args) - 1 + selection = int(selection) - 1 except ValueError: - if cmd.startswith("a") and add: - mode = "add" - selection = args + caller.msg("|rInvalid input|n") else: + # edits are on the form 'edit if cmd.startswith("e") and edit: mode = "edit" elif examine: mode = "look" try: - selection = available_choices[number] + selection = available_choices[selection] except IndexError: - caller.msg("|rInvalid index") + caller.msg("|rInvalid index|n") mode = None - return mode, selection + return mode, selection, new_value def _relay_to_edit_or_add(caller, raw_string, **kwargs): pass @@ -1239,8 +1241,8 @@ def list_node(option_generator, select=None, examine=None, edit=None, add=None, if mode == "arbitrary": # freeform input, we must parse it for the allowed commands (look/edit) - mode, selection = _input_parser(caller, raw_string, - **{"available_choices": page}) + mode, selection, new_value = _input_parser(caller, raw_string, + **{"available_choices": page}) if examine and mode == "look": # look mode - we are examining a given entry diff --git a/evennia/utils/spawner.py b/evennia/utils/spawner.py index 2fbdbaed21..c4f732ebbb 100644 --- a/evennia/utils/spawner.py +++ b/evennia/utils/spawner.py @@ -1219,7 +1219,6 @@ def node_meta_desc(caller): return text, options - def node_meta_tags(caller): metaprot = _get_menu_metaprot(caller) text = ["|wMeta-Tags|n can be used to classify and find prototypes. Tags are case-insensitive. " From ab811c81c8a9bf7c1afac07bd931b886c9472b4a Mon Sep 17 00:00:00 2001 From: Brenden Tuck Date: Sun, 8 Apr 2018 12:33:38 -0400 Subject: [PATCH 087/208] Added an undo button for multi-level undo of splits --- .../static/webclient/css/webclient.css | 13 ++++ .../static/webclient/js/splithandler.js | 68 ++++++++++++++++++- .../static/webclient/js/webclient_gui.js | 5 +- .../templates/webclient/webclient.html | 1 + 4 files changed, 85 insertions(+), 2 deletions(-) diff --git a/evennia/web/webclient/static/webclient/css/webclient.css b/evennia/web/webclient/static/webclient/css/webclient.css index 75dd91ce2a..7a33cfa207 100644 --- a/evennia/web/webclient/static/webclient/css/webclient.css +++ b/evennia/web/webclient/static/webclient/css/webclient.css @@ -155,6 +155,19 @@ div {margin:0px;} cursor: pointer; } +#undobutton { + width: 2rem; + font-size: 2rem; + color: #a6a6a6; + background-color: transparent; + border: 0px; +} + +#undobutton:hover { + color: white; + cursor: pointer; +} + .button { width: fit-content; padding: 1em; diff --git a/evennia/web/webclient/static/webclient/js/splithandler.js b/evennia/web/webclient/static/webclient/js/splithandler.js index aa6ea4364a..81210df854 100644 --- a/evennia/web/webclient/static/webclient/js/splithandler.js +++ b/evennia/web/webclient/static/webclient/js/splithandler.js @@ -1,6 +1,7 @@ // Use split.js to create a basic ui var SplitHandler = (function () { var split_panes = {}; + var backout_list = new Array; var set_pane_types = function(splitpane, types) { split_panes[splitpane]['types'] = types; @@ -26,7 +27,8 @@ var SplitHandler = (function () { first_div.append( first_sub ); second_div.append( second_sub ); - // update the split_panes array to remove this pane name + // update the split_panes array to remove this pane name, but store it for the backout stack + var backout_settings = split_panes[splitpane]; delete( split_panes[splitpane] ); // now vaporize the current split_N-sub placeholder and create two new panes. @@ -45,6 +47,69 @@ var SplitHandler = (function () { // store our new split sub-divs for future splits/uses by the main UI. split_panes[pane_name1] = { 'types': [], 'update_method': update_method1 }; split_panes[pane_name2] = { 'types': [], 'update_method': update_method2 }; + + // add our new split to the backout stack + backout_list.push( {'pane1': pane_name1, 'pane2': pane_name2, 'undo': backout_settings} ); + } + + + var undo_split = function() { + // pop off the last split pair + var back = backout_list.pop(); + if( !back ) { + return; + } + + // Collect all the divs/subs in play + var pane1 = back['pane1']; + var pane2 = back['pane2']; + var pane1_sub = $('#'+pane1+'-sub'); + var pane2_sub = $('#'+pane2+'-sub'); + var pane1_parent = $('#'+pane1).parent(); + var pane2_parent = $('#'+pane2).parent(); + + if( pane1_parent.attr('id') != pane2_parent.attr('id') ) { + // sanity check failed...somebody did something weird...bail out + console.log( pane1 ); + console.log( pane2 ); + console.log( pane1_parent ); + console.log( pane2_parent ); + return; + } + + // create a new sub-pane in the panes parent + var parent_sub = $( '
' ) + + // check to see if the special #messagewindow is in either of our sub-panes. + var msgwindow = pane1_sub.find('#messagewindow') + if( !msgwindow ) { + //didn't find it in pane 1, try pane 2 + msgwindow = pane2_sub.find('#messagewindow') + } + if( msgwindow ) { + // It is, so collect all contents into it instead of our parent_sub div + // then move it to parent sub div, this allows future #messagewindow divs to flow properly + msgwindow.append( pane1_sub.contents() ); + msgwindow.append( pane2_sub.contents() ); + parent_sub.append( msgwindow ); + } else { + //didn't find it, so move the contents of the two panes' sub-panes into the new sub-pane + parent_sub.append( pane1_sub.contents() ); + parent_sub.append( pane2_sub.contents() ); + } + + // clear the parent + pane1_parent.empty(); + + // add the new sub-pane back to the parent div + pane1_parent.append(parent_sub); + + // pull the sub-div's from split_panes + delete split_panes[pane1]; + delete split_panes[pane2]; + + // add our parent pane back into the split_panes list for future splitting + split_panes[pane1_parent.attr('id')] = back['undo']; } @@ -75,5 +140,6 @@ var SplitHandler = (function () { set_pane_types: set_pane_types, dynamic_split: dynamic_split, split_panes: split_panes, + undo_split: undo_split, } })(); diff --git a/evennia/web/webclient/static/webclient/js/webclient_gui.js b/evennia/web/webclient/static/webclient/js/webclient_gui.js index b975ae7044..8929a7529c 100644 --- a/evennia/web/webclient/static/webclient/js/webclient_gui.js +++ b/evennia/web/webclient/static/webclient/js/webclient_gui.js @@ -15,7 +15,7 @@ (function () { "use strict" -var num_splits = 0; +var num_splits = 0; //unique id counter for default split-panel names var options = {}; @@ -544,9 +544,12 @@ $(document).ready(function() { SplitHandler.init(); $("#splitbutton").bind("click", onSplitDialog); $("#panebutton").bind("click", onPaneControlDialog); + $("#undobutton").bind("click", SplitHandler.undo_split); + $("#optionsbutton").hide(); } else { $("#splitbutton").hide(); $("#panebutton").hide(); + $("#undobutton").hide(); } if ("Notification" in window) { diff --git a/evennia/web/webclient/templates/webclient/webclient.html b/evennia/web/webclient/templates/webclient/webclient.html index b750257048..74bef631cf 100644 --- a/evennia/web/webclient/templates/webclient/webclient.html +++ b/evennia/web/webclient/templates/webclient/webclient.html @@ -12,6 +12,7 @@ +
From 44ee41a5d12dfbcf8d5d0c84b093a0c856362cf6 Mon Sep 17 00:00:00 2001 From: Griatch Date: Mon, 9 Apr 2018 23:13:54 +0200 Subject: [PATCH 088/208] Add functioning, if primitive edit/add to decorator --- evennia/utils/evmenu.py | 41 ++++++++++++++++++++-------------------- evennia/utils/spawner.py | 31 +++++++++++++++++++++++------- 2 files changed, 45 insertions(+), 27 deletions(-) diff --git a/evennia/utils/evmenu.py b/evennia/utils/evmenu.py index 2cb282a641..edbf64755b 100644 --- a/evennia/utils/evmenu.py +++ b/evennia/utils/evmenu.py @@ -1170,10 +1170,11 @@ def list_node(option_generator, select=None, examine=None, edit=None, add=None, except Exception: caller.msg("|rInvalid choice.|n") else: - try: - return select(caller, selection) - except Exception: - logger.log_trace() + if select: + try: + return select(caller, selection) + except Exception: + logger.log_trace() return None def _input_parser(caller, raw_string, **kwargs): @@ -1185,7 +1186,7 @@ def list_node(option_generator, select=None, examine=None, edit=None, add=None, which processors are available. """ - mode, selection, new_value = None, None, None + mode, selection, args = None, None, None available_choices = kwargs.get("available_choices", []) cmd, args = re.search(r"(^[a-zA-Z]*)\s*(.*?)$", raw_string).groups() @@ -1193,13 +1194,12 @@ def list_node(option_generator, select=None, examine=None, edit=None, add=None, cmd = cmd.lower().strip() if cmd.startswith('a') and add: mode = "add" - new_value = args else: - selection, new_value = re.search(r"(^[0-9]*)\s*(.*?)$", args).groups() + selection, args = re.search(r"(^[0-9]*)\s*(.*?)$", args).groups() try: selection = int(selection) - 1 except ValueError: - caller.msg("|rInvalid input|n") + mode = "look" else: # edits are on the form 'edit if cmd.startswith("e") and edit: @@ -1212,7 +1212,7 @@ def list_node(option_generator, select=None, examine=None, edit=None, add=None, caller.msg("|rInvalid index|n") mode = None - return mode, selection, new_value + return mode, selection, args def _relay_to_edit_or_add(caller, raw_string, **kwargs): pass @@ -1222,9 +1222,11 @@ def list_node(option_generator, select=None, examine=None, edit=None, add=None, mode = kwargs.get("list_mode", None) option_list = option_generator(caller) if callable(option_generator) else option_generator + print("option_list: {}, {}".format(option_list, mode)) + npages = 0 page_index = 0 - page = None + page = [] options = [] if option_list: @@ -1241,7 +1243,7 @@ def list_node(option_generator, select=None, examine=None, edit=None, add=None, if mode == "arbitrary": # freeform input, we must parse it for the allowed commands (look/edit) - mode, selection, new_value = _input_parser(caller, raw_string, + mode, selection, args = _input_parser(caller, raw_string, **{"available_choices": page}) if examine and mode == "look": @@ -1262,7 +1264,7 @@ def list_node(option_generator, select=None, examine=None, edit=None, add=None, elif add and mode == 'add': # add mode - the selection is the new value try: - text, options = add(caller, selection, **kwargs) + text, options = add(caller, args) except Exception: logger.log_trace() text = "|rCould not add." @@ -1270,7 +1272,7 @@ def list_node(option_generator, select=None, examine=None, edit=None, add=None, elif edit and mode == 'edit': try: - text, options = edit(caller, selection, **kwargs) + text, options = edit(caller, selection, args) except Exception: logger.log_trace() text = "|Could not edit." @@ -1279,14 +1281,13 @@ def list_node(option_generator, select=None, examine=None, edit=None, add=None, else: # normal mode - list - if select: - # We have a processor to handle selecting an entry + # We have a processor to handle selecting an entry - # dynamic, multi-page option list. Each selection leads to the `select` - # callback being called with a result from the available choices - options.extend([{"desc": opt, - "goto": (_select_parser, - {"available_choices": page})} for opt in page]) + # dynamic, multi-page option list. Each selection leads to the `select` + # callback being called with a result from the available choices + options.extend([{"desc": opt, + "goto": (_select_parser, + {"available_choices": page})} for opt in page]) if npages > 1: # if the goto callable returns None, the same node is rerun, and diff --git a/evennia/utils/spawner.py b/evennia/utils/spawner.py index c4f732ebbb..d5750c1629 100644 --- a/evennia/utils/spawner.py +++ b/evennia/utils/spawner.py @@ -1066,16 +1066,19 @@ def _caller_tags(caller): tags = prot.get("tags") return tags -def _add_tags(caller, tag, **kwargs): + +def _add_tag(caller, tag, **kwargs): tag = tag.strip().lower() metaprot = _get_menu_metaprot(caller) - tags = metaprot.tags + prot = metaprot.prototype + tags = prot.get('tags', []) if tags: if tag not in tags: tags.append(tag) else: tags = [tag] - metaprot.tags = tags + prot['tags'] = tags + _set_menu_metaprot(caller, "prototype", prot) text = kwargs.get("text") if not text: text = "Added tag {}. (return to continue)".format(tag) @@ -1084,12 +1087,26 @@ def _add_tags(caller, tag, **kwargs): return text, options -def _edit_tag(caller, tag, **kwargs): - tag = tag.strip().lower() +def _edit_tag(caller, old_tag, new_tag, **kwargs): metaprot = _get_menu_metaprot(caller) - #TODO change in evmenu so one can do e 3 right away, parse & store value in kwarg + prototype = metaprot.prototype + tags = prototype.get('tags', []) -@list_node(_caller_tags, edit=_edit_tags) + old_tag = old_tag.strip().lower() + new_tag = new_tag.strip().lower() + tags[tags.index(old_tag)] = new_tag + prototype['tags'] = tags + _set_menu_metaprot(caller, 'prototype', prototype) + + text = kwargs.get('text') + if not text: + text = "Changed tag {} to {}.".format(old_tag, new_tag) + options = {"key": "_default", + "goto": lambda caller: None} + return text, options + + +@list_node(_caller_tags, edit=_edit_tag, add=_add_tag) def node_tags(caller): text = "Set the prototype's |yTags|n." options = _wizard_options("tags", "attrs", "locks") From 235d41a10494be37f6c23e893ff2ea5a17a70282 Mon Sep 17 00:00:00 2001 From: CloudKeeper1 Date: Sat, 14 Apr 2018 00:23:52 +1000 Subject: [PATCH 089/208] Wrong symbol on line 499 Wrong symbol on line 499 --- evennia/commands/default/general.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/evennia/commands/default/general.py b/evennia/commands/default/general.py index 2f4c51a227..f9634ed675 100644 --- a/evennia/commands/default/general.py +++ b/evennia/commands/default/general.py @@ -496,7 +496,7 @@ class CmdWhisper(COMMAND_DEFAULT_CLASS): Usage: whisper = - whisper , = , = Talk privately to one or more characters in your current location, without others in the room being informed. From 4fe4f0656e2e5c1b8e572f97913a75be870ce15d Mon Sep 17 00:00:00 2001 From: Griatch Date: Sun, 15 Apr 2018 00:03:36 +0200 Subject: [PATCH 090/208] Simplify list_node decorator --- evennia/utils/evmenu.py | 377 +++++++++++----------------------------- 1 file changed, 102 insertions(+), 275 deletions(-) diff --git a/evennia/utils/evmenu.py b/evennia/utils/evmenu.py index edbf64755b..60373e3a5a 100644 --- a/evennia/utils/evmenu.py +++ b/evennia/utils/evmenu.py @@ -987,137 +987,6 @@ class EvMenu(object): return separator1 + "|n" + nodetext + "|n" + separator2 + "|n" + optionstext -# ----------------------------------------------------------- -# -# Edit node (decorator turning a node into an editing -# point for a given resource -# -# ----------------------------------------------------------- - -def edit_node(edit_text, add_text, edit_callback, add_callback, get_choices=None): - """ - Decorator for turning an EvMenu node into an editing - page. Will add new options, prepending those options - added in the node. - - Args: - edit_text (str or callable): Will be used as text for the edit node. If - callable, it will be called as edittext(selection) - and should return the node text for the edit-node, probably listing - the current value of all editable propnames, if possible. - add_text (str) or callable: Gives text for node in add-mode. If a callable, - called as add_text() and should return the text for the node. - edit_callback (callable): Will be called as edit_callback(editable, raw_string) - and should return a boolean True/False if the setting of the property - succeeded or not. The value will always be a string and should be - converted as needed. - add_callback (callable): Will be called as add_callback(raw_string) and - should return a boolean True/False if the addition succeded. - - get_choices (callable): Produce the available editable choices. If this - is not given, the `goto` callable must have been provided with the - kwarg `available_choices` by the decorated node. - - """ - - def decorator(func): - - def _setter_goto(caller, raw_string, **kwargs): - editable = kwargs.get("editable") - mode = kwargs.get("edit_node_mode") - try: - if mode == 'edit': - is_ok = edit_callback(editable, raw_string) - else: - is_ok = add_callback(raw_string) - except Exception: - logger.log_trace() - if not is_ok: - caller.msg("|rValue could not be set.") - return None - - def _patch_goto(caller, raw_string, **kwargs): - - # parse incoming string to figure out if there is a match to edit/add - match = re.search(r"(^[a-zA-Z]*)\s*([0-9]*)$", raw_string) - cmd, number = match.groups() - edit_mode = None - available_choices = None - selection = None - - if get_choices: - available_choices = make_iter(get_choices(caller, raw_string, **kwargs)) - if not available_choices: - available_choices = kwargs.get("available_choices", []) - - if available_choices and cmd.startswith("e"): - try: - index = int(cmd) - 1 - selection = available_choices[index] - edit_mode = 'edit' - except (IndexError, TypeError): - caller.msg("|rNot a valid 'edit' command.") - - if cmd.startswith("a") and not number: - # add mode - edit_mode = "add" - - if edit_mode: - # replace with edit text/options - text = edit_text(selection) if edit_mode == "edit" else add_text() - options = ({"key": "_default", - "goto": (_setter_goto, - {"selection": selection, - "edit_node_mode": edit_mode})}) - return text, options - - # no matches - pass through to the original decorated goto instruction - - decorated_opt = kwargs.get("decorated_opt") - - if decorated_opt: - # use EvMenu's parser to get the goto/goto-kwargs out of - # the decorated option structure - dec_goto, dec_goto_kwargs, _, _ = \ - caller.ndb._menutree.extract_goto_exec("edit-node", decorated_opt) - - if callable(dec_goto): - try: - return dec_goto(caller, raw_string, - **{dec_goto_kwargs if dec_goto_kwargs else {}}) - except Exception: - caller.msg("|rThere was an error in the edit node.") - logger.log_trace() - return None - - def _edit_node(caller, raw_string, **kwargs): - - text, options = func(caller, raw_string, **kwargs) - - if options: - # find eventual _default in options and patch it with a handler for - # catching editing - - decorated_opt = None - iopt = 0 - for iopt, optdict in enumerate(options): - if optdict.get('key') == "_default": - decorated_opt = optdict - break - - if decorated_opt: - # inject our wrapper over the original goto instruction for the - # _default action (save the original) - options[iopt]["goto"] = (_patch_goto, - {"decorated_opt": decorated_opt}) - - return text, options - - return _edit_node - return decorator - - - # ----------------------------------------------------------- # # List node (decorator turning a node into a list with @@ -1125,7 +994,7 @@ def edit_node(edit_text, add_text, edit_callback, add_callback, get_choices=None # # ----------------------------------------------------------- -def list_node(option_generator, select=None, examine=None, edit=None, add=None, pagesize=10): +def list_node(option_generator, select=None, pagesize=10): """ Decorator for making an EvMenu node into a multi-page list node. Will add new options, prepending those options added in the node. @@ -1135,25 +1004,22 @@ def list_node(option_generator, select=None, examine=None, edit=None, add=None, that is called as option_generator(caller) to produce such a list. select (callable, option): Will be called as select(caller, menuchoice) where menuchoice is the chosen option as a string. Should return the target node to - goto after this selection. Note that if this is not given, the decorated node must - itself provide a way to continue from the node! - examine (callable, optional): If given, allows for examining options in detail. Will - be called with examine(caller, menuchoice) and should return a text string to - display in-place in the node. - edit (callable, optional): If given, this callable will be called as edit(caller, - menuchoice, **kwargs) and should return a complete (text, options) tuple (like a node). - add (callable optional): If given, this callable will be called as add(caller, menuchoice, - **kwargs) and should return a complete (text, options) tuple (like a node). - + goto after this selection (or None to repeat the list-node). Note that if this is not + given, the decorated node must itself provide a way to continue from the node! pagesize (int): How many options to show per page. Example: - @list_node(['foo', 'bar'], examine, select) + @list_node(['foo', 'bar'], select) def node_index(caller): text = "describing the list" return text, [] + Notes: + All normal `goto` or `exec` callables returned from the decorated nodes will, if they accept + **kwargs, get a new kwarg 'available_choices' injected. These are the ordered list of named + options (descs) visible on the current node page. + """ def decorator(func): @@ -1177,53 +1043,44 @@ def list_node(option_generator, select=None, examine=None, edit=None, add=None, logger.log_trace() return None - def _input_parser(caller, raw_string, **kwargs): - """ - Parse which input was given, select from option_list. - - Understood input is [cmd], where [cmd] is either empty (`select`) - or one of the supported actions `look`, `edit` or `add` depending on - which processors are available. - - """ - mode, selection, args = None, None, None - available_choices = kwargs.get("available_choices", []) - - cmd, args = re.search(r"(^[a-zA-Z]*)\s*(.*?)$", raw_string).groups() - - cmd = cmd.lower().strip() - if cmd.startswith('a') and add: - mode = "add" - else: - selection, args = re.search(r"(^[0-9]*)\s*(.*?)$", args).groups() - try: - selection = int(selection) - 1 - except ValueError: - mode = "look" - else: - # edits are on the form 'edit - if cmd.startswith("e") and edit: - mode = "edit" - elif examine: - mode = "look" - try: - selection = available_choices[selection] - except IndexError: - caller.msg("|rInvalid index|n") - mode = None - - return mode, selection, args - - def _relay_to_edit_or_add(caller, raw_string, **kwargs): - pass +# def _input_parser(caller, raw_string, **kwargs): +# """ +# Parse which input was given, select from option_list. +# +# +# """ +# mode, selection, args = None, None, None +# available_choices = kwargs.get("available_choices", []) +# +# cmd, args = re.search(r"(^[a-zA-Z]*)\s*(.*?)$", raw_string).groups() +# +# cmd = cmd.lower().strip() +# if cmd.startswith('a') and add: +# mode = "add" +# else: +# selection, args = re.search(r"(^[0-9]*)\s*(.*?)$", args).groups() +# try: +# selection = int(selection) - 1 +# except ValueError: +# mode = "look" +# else: +# # edits are on the form 'edit +# if cmd.startswith("e") and edit: +# mode = "edit" +# elif examine: +# mode = "look" +# try: +# selection = available_choices[selection] +# except IndexError: +# caller.msg("|rInvalid index|n") +# mode = None +# +# return mode, selection, args def _list_node(caller, raw_string, **kwargs): - mode = kwargs.get("list_mode", None) option_list = option_generator(caller) if callable(option_generator) else option_generator - print("option_list: {}, {}".format(option_list, mode)) - npages = 0 page_index = 0 page = [] @@ -1231,113 +1088,83 @@ def list_node(option_generator, select=None, examine=None, edit=None, add=None, if option_list: nall_options = len(option_list) - pages = [option_list[ind:ind + pagesize] for ind in range(0, nall_options, pagesize)] + pages = [option_list[ind:ind + pagesize] + for ind in range(0, nall_options, pagesize)] npages = len(pages) page_index = max(0, min(npages - 1, kwargs.get("optionpage_index", 0))) page = pages[page_index] text = "" - selection = None extra_text = None - if mode == "arbitrary": - # freeform input, we must parse it for the allowed commands (look/edit) - mode, selection, args = _input_parser(caller, raw_string, - **{"available_choices": page}) + # dynamic, multi-page option list. Each selection leads to the `select` + # callback being called with a result from the available choices + options.extend([{"desc": opt, + "goto": (_select_parser, + {"available_choices": page})} for opt in page]) - if examine and mode == "look": - # look mode - we are examining a given entry - try: - text = examine(caller, selection) - except Exception: - logger.log_trace() - text = "|rCould not view." - options.extend([{"key": ("|wb|Wack|n", "b"), - "goto": (lambda caller: None, - {"optionpage_index": page_index})}, - {"key": "_default", - "goto": (lambda caller: None, - {"optionpage_index": page_index})}]) - return text, options - - elif add and mode == 'add': - # add mode - the selection is the new value - try: - text, options = add(caller, args) - except Exception: - logger.log_trace() - text = "|rCould not add." - return text, options - - elif edit and mode == 'edit': - try: - text, options = edit(caller, selection, args) - except Exception: - logger.log_trace() - text = "|Could not edit." - return text, options - - else: - # normal mode - list - - # We have a processor to handle selecting an entry - - # dynamic, multi-page option list. Each selection leads to the `select` - # callback being called with a result from the available choices - options.extend([{"desc": opt, - "goto": (_select_parser, - {"available_choices": page})} for opt in page]) - - if npages > 1: - # if the goto callable returns None, the same node is rerun, and - # kwargs not used by the callable are passed on to the node. This - # allows us to call ourselves over and over, using different kwargs. - options.append({"key": ("|Wcurrent|n", "c"), - "desc": "|W({}/{})|n".format(page_index + 1, npages), - "goto": (lambda caller: None, - {"optionpage_index": page_index})}) - if page_index > 0: - options.append({"key": ("|wp|Wrevious page|n", "p"), - "goto": (lambda caller: None, - {"optionpage_index": page_index - 1})}) - if page_index < npages - 1: - options.append({"key": ("|wn|Wext page|n", "n"), - "goto": (lambda caller: None, - {"optionpage_index": page_index + 1})}) - - # this catches arbitrary input and reruns this node with the 'arbitrary' mode - # this could mean input on the form 'look ' or 'edit ' - options.append({"key": "_default", + if npages > 1: + # if the goto callable returns None, the same node is rerun, and + # kwargs not used by the callable are passed on to the node. This + # allows us to call ourselves over and over, using different kwargs. + options.append({"key": ("|Wcurrent|n", "c"), + "desc": "|W({}/{})|n".format(page_index + 1, npages), "goto": (lambda caller: None, - {"optionpage_index": page_index, - "available_choices": page, - "list_mode": "arbitrary"})}) + {"optionpage_index": page_index})}) + if page_index > 0: + options.append({"key": ("|wp|Wrevious page|n", "p"), + "goto": (lambda caller: None, + {"optionpage_index": page_index - 1})}) + if page_index < npages - 1: + options.append({"key": ("|wn|Wext page|n", "n"), + "goto": (lambda caller: None, + {"optionpage_index": page_index + 1})}) - # add data from the decorated node + # add data from the decorated node - extra_options = [] + decorated_options = [] + try: + text, decorated_options = func(caller, raw_string) + except TypeError: try: - text, extra_options = func(caller, raw_string) - except TypeError: - try: - text, extra_options = func(caller) - except Exception: - raise + text, decorated_options = func(caller) except Exception: - logger.log_trace() - print("extra_options:", extra_options) + raise + except Exception: + logger.log_trace() + else: + if isinstance(decorated_options, {}): + decorated_options = [decorated_options] else: - if isinstance(extra_options, {}): - extra_options = [extra_options] - else: - extra_options = make_iter(extra_options) + decorated_options = make_iter(decorated_options) - options.extend(extra_options) - text = text + "\n\n" + extra_text if extra_text else text - text += "\n\n(Make a choice or enter 'look ' to examine an option closer)" + extra_options = [] + for eopt in decorated_options: + cback = ("goto" in eopt and "goto") or ("exec" in eopt and "exec") or None + if cback: + signature = eopt[cback] + if callable(signature): + # callable with no kwargs defined + eopt[cback] = (signature, {"available_choices": page}) + elif is_iter(signature): + if len(signature) > 1 and isinstance(signature[1], dict): + signature[1]["available_choices"] = page + eopt[cback] = signature + elif signature: + # a callable alone in a tuple (i.e. no previous kwargs) + eopt[cback] = (signature[0], {"available_choices": page}) + else: + # malformed input. + logger.log_err("EvMenu @list_node decorator found " + "malformed option to decorate: {}".format(eopt)) + extra_options.append(eopt) - return text, options + options.extend(extra_options) + text = text + "\n\n" + extra_text if extra_text else text + text += "\n\n(Make a choice or enter 'look ' to examine an option closer)" + + return text, options return _list_node return decorator From 1bbffa2fc5fab15b3c5e1db9929e5d209daeaa33 Mon Sep 17 00:00:00 2001 From: Griatch Date: Sun, 15 Apr 2018 20:13:38 +0200 Subject: [PATCH 091/208] non-functioning spawner --- evennia/utils/spawner.py | 68 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 67 insertions(+), 1 deletion(-) diff --git a/evennia/utils/spawner.py b/evennia/utils/spawner.py index d5750c1629..acc8cb2457 100644 --- a/evennia/utils/spawner.py +++ b/evennia/utils/spawner.py @@ -105,6 +105,7 @@ prototype, override its name with an empty dict. from __future__ import print_function import copy +from ast import literal_eval from django.conf import settings from random import randint import evennia @@ -125,6 +126,11 @@ _MODULE_PROTOTYPES = {} _MODULE_PROTOTYPE_MODULES = {} _MENU_CROP_WIDTH = 15 +_MENU_ATTR_LITERAL_EVAL_ERROR = ( + "|rCritical Python syntax error in your value. Only primitive Python structures are allowed.\n" + "You also need to use correct Python syntax. Remember especially to put quotes around all " + "strings inside lists and dicts.|n") + class PermissionError(RuntimeError): pass @@ -933,7 +939,7 @@ def _prototype_select(caller, prototype): return ret -@list_node(_all_prototypes, _prototype_select, examine=_prototype_examine) +@list_node(_all_prototypes, select=_prototype_select, examine=_prototype_examine) def node_prototype(caller): metaprot = _get_menu_metaprot(caller) prot = metaprot.prototype @@ -1039,6 +1045,66 @@ def node_aliases(caller): return text, options +def _caller_attrs(caller): + metaprot = _get_menu_metaprot(caller) + attrs = metaprot.prototype.get("attrs", []) + return attrs + + +def _attrparse(caller, attr_string): + "attr is entering on the form 'attr = value'" + + if '=' in attr_string: + attrname, value = (part.strip() for part in attr_string.split('=', 1)) + attrname = attrname.lower() + if attrname: + try: + value = literal_eval(value) + except SyntaxError: + caller.msg(_MENU_ATTR_LITERAL_EVAL_ERROR) + else: + return attrname, value + else: + return None, None + + +def _add_attr(caller, attr_string, **kwargs): + attrname, value = _attrparse(caller, attr_string) + if attrname: + metaprot = _get_menu_metaprot(caller) + prot = metaprot.prototype + prot['attrs'][attrname] = value + _set_menu_metaprot(caller, "prototype", prot) + text = "Added" + else: + text = "Attribute must be given as 'attrname = ' where uses valid Python." + options = {"key": "_default", + "goto": lambda caller: None} + return text, options + + +def _edit_attr(caller, attrname, new_value, **kwargs): + attrname, value = _attrparse("{}={}".format(caller, attrname, new_value)) + if attrname: + metaprot = _get_menu_metaprot(caller) + prot = metaprot.prototype + prot['attrs'][attrname] = value + text = "Edited Attribute {} = {}".format(attrname, value) + else: + text = "Attribute value must be valid Python." + options = {"key": "_default", + "goto": lambda caller: None} + return text, options + + +def _examine_attr(caller, selection): + metaprot = _get_menu_metaprot(caller) + prot = metaprot.prototype + value = prot['attrs'][selection] + return "Attribute {} = {}".format(selection, value) + + +@list_node(_caller_attrs, edit=_edit_attr, add=_add_attr, examine=_examine_attr) def node_attrs(caller): metaprot = _get_menu_metaprot(caller) prot = metaprot.prototype From 03a107d0225b6bb4d5dd19a488299341e1e19b45 Mon Sep 17 00:00:00 2001 From: Griatch Date: Sun, 15 Apr 2018 22:06:15 +0200 Subject: [PATCH 092/208] Made code run without traceback; in future, use select action to enter edit node, separate add command to enter add mode --- evennia/utils/spawner.py | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/evennia/utils/spawner.py b/evennia/utils/spawner.py index acc8cb2457..bbed685b5c 100644 --- a/evennia/utils/spawner.py +++ b/evennia/utils/spawner.py @@ -929,8 +929,9 @@ def _all_prototypes(caller): def _prototype_examine(caller, prototype_name): metaprot = search_prototype(key=prototype_name) if metaprot: - return metaproto_to_str(metaprot[0]) - return "Prototype not registered." + caller.msg(metaproto_to_str(metaprot[0])) + caller.msg("Prototype not registered.") + return None def _prototype_select(caller, prototype): @@ -939,7 +940,7 @@ def _prototype_select(caller, prototype): return ret -@list_node(_all_prototypes, select=_prototype_select, examine=_prototype_examine) +@list_node(_all_prototypes, _prototype_select) def node_prototype(caller): metaprot = _get_menu_metaprot(caller) prot = metaprot.prototype @@ -952,6 +953,9 @@ def node_prototype(caller): text.append("Parent prototype is not set") text = "\n\n".join(text) options = _wizard_options("prototype", "meta_key", "typeclass", color="|W") + options.append({"key": "_default", + "goto": _prototype_examine}) + return text, options @@ -978,7 +982,8 @@ def _typeclass_examine(caller, typeclass_path): typeclass_path=typeclass_path, docstring=docstr) else: txt = "This is typeclass |y{}|n.".format(typeclass) - return txt + caller.msg(txt) + return None def _typeclass_select(caller, typeclass): @@ -987,7 +992,7 @@ def _typeclass_select(caller, typeclass): return ret -@list_node(_all_typeclasses, _typeclass_select, examine=_typeclass_examine) +@list_node(_all_typeclasses, _typeclass_select) def node_typeclass(caller): metaprot = _get_menu_metaprot(caller) prot = metaprot.prototype @@ -1001,6 +1006,8 @@ def node_typeclass(caller): typeclass=settings.BASE_OBJECT_TYPECLASS)) text = "\n\n".join(text) options = _wizard_options("typeclass", "prototype", "key", color="|W") + options.append({"key": "_default", + "goto": _typeclass_examine}) return text, options @@ -1104,7 +1111,7 @@ def _examine_attr(caller, selection): return "Attribute {} = {}".format(selection, value) -@list_node(_caller_attrs, edit=_edit_attr, add=_add_attr, examine=_examine_attr) +@list_node(_caller_attrs) def node_attrs(caller): metaprot = _get_menu_metaprot(caller) prot = metaprot.prototype @@ -1172,7 +1179,7 @@ def _edit_tag(caller, old_tag, new_tag, **kwargs): return text, options -@list_node(_caller_tags, edit=_edit_tag, add=_add_tag) +@list_node(_caller_tags) def node_tags(caller): text = "Set the prototype's |yTags|n." options = _wizard_options("tags", "attrs", "locks") From 7ae3a434241c17b25b6b11b6514ceff509633524 Mon Sep 17 00:00:00 2001 From: Aditya Arora Date: Mon, 16 Apr 2018 18:41:02 +0530 Subject: [PATCH 093/208] Update rpsystem.py --- evennia/contrib/rpsystem.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/evennia/contrib/rpsystem.py b/evennia/contrib/rpsystem.py index a56d2de731..efba0fe7fd 100644 --- a/evennia/contrib/rpsystem.py +++ b/evennia/contrib/rpsystem.py @@ -1088,7 +1088,7 @@ class CmdMask(RPCommand): if self.cmdstring == "mask": # wear a mask if not self.args: - caller.msg("Usage: (un)wearmask sdesc") + caller.msg("Usage: (un)mask sdesc") return if caller.db.unmasked_sdesc: caller.msg("You are already wearing a mask.") @@ -1111,7 +1111,7 @@ class CmdMask(RPCommand): del caller.db.unmasked_sdesc caller.locks.remove("enable_recog") caller.sdesc.add(old_sdesc) - caller.msg("You remove your mask and is again '%s'." % old_sdesc) + caller.msg("You remove your mask and are again '%s'." % old_sdesc) class RPSystemCmdSet(CmdSet): From 9a7583a4d7313f7c0502921863c023c03c6148f5 Mon Sep 17 00:00:00 2001 From: Griatch Date: Mon, 16 Apr 2018 20:18:21 +0200 Subject: [PATCH 094/208] Add test_spawner --- evennia/utils/tests/test_spawner.py | 63 +++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 evennia/utils/tests/test_spawner.py diff --git a/evennia/utils/tests/test_spawner.py b/evennia/utils/tests/test_spawner.py new file mode 100644 index 0000000000..e29ee8c151 --- /dev/null +++ b/evennia/utils/tests/test_spawner.py @@ -0,0 +1,63 @@ +""" +Unit test for the spawner + +""" + +from evennia.utils.test_resources import EvenniaTest +from evennia.utils import spawner + + +class TestPrototypeStorage(EvenniaTest): + + def setUp(self): + super(TestPrototypeStorage, self).setUp() + self.prot1 = {"key": "testprototype"} + self.prot2 = {"key": "testprototype2"} + self.prot3 = {"key": "testprototype3"} + + def _get_metaproto( + self, key='testprototype', desc='testprototype', locks=['edit:id(6) or perm(Admin)', 'use:all()'], + tags=[], prototype={"key": "testprototype"}): + return spawner.build_metaproto(key, desc, locks, tags, prototype) + + def _to_metaproto(self, db_prototype): + return spawner.build_metaproto( + db_prototype.key, db_prototype.desc, db_prototype.locks.all(), + db_prototype.tags.get(category="db_prototype", return_list=True), + db_prototype.attributes.get("prototype")) + + def test_prototype_storage(self): + + prot = spawner.save_db_prototype(self.char1, "testprot", self.prot1, desc='testdesc0', tags=["foo"]) + + self.assertTrue(bool(prot)) + self.assertEqual(prot.db.prototype, self.prot1) + self.assertEqual(prot.desc, "testdesc0") + + prot = spawner.save_db_prototype(self.char1, "testprot", self.prot1, desc='testdesc', tags=["fooB"]) + self.assertEqual(prot.db.prototype, self.prot1) + self.assertEqual(prot.desc, "testdesc") + self.assertTrue(bool(prot.tags.get("fooB", "db_prototype"))) + + self.assertEqual(list(prot.__class__.objects.get_by_tag("foo", "db_prototype")), [prot]) + + prot2 = spawner.save_db_prototype(self.char1, "testprot2", self.prot2, desc='testdesc2b', tags=["foo"]) + self.assertEqual(list(prot.__class__.objects.get_by_tag("foo", "db_prototype")), [prot, prot2]) + + prot3 = spawner.save_db_prototype(self.char1, "testprot2", self.prot3, desc='testdesc2') + self.assertEqual(prot2.id, prot3.id) + self.assertEqual(list(prot.__class__.objects.get_by_tag("foo", "db_prototype")), [prot, prot2]) + + # returns DBPrototype + self.assertEqual(list(spawner.search_db_prototype("testprot")), [prot]) + + # returns metaprotos + prot = self._to_metaproto(prot) + prot3 = self._to_metaproto(prot3) + self.assertEqual(list(spawner.search_prototype("testprot")), [prot]) + self.assertEqual(list(spawner.search_prototype("testprot", return_meta=False)), [self.prot1]) + # partial match + self.assertEqual(list(spawner.search_prototype("prot")), [prot, prot3]) + self.assertEqual(list(spawner.search_prototype(tags="foo")), [prot, prot3]) + + self.assertTrue(str(unicode(spawner.list_prototypes(self.char1)))) From 33347bd2cbfd6d18b5c0a3adf8fd1e4be54ae39d Mon Sep 17 00:00:00 2001 From: Griatch Date: Mon, 16 Apr 2018 20:34:52 +0200 Subject: [PATCH 095/208] Fix unittest --- evennia/commands/default/tests.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/evennia/commands/default/tests.py b/evennia/commands/default/tests.py index 6847afe8c4..7f0ed17924 100644 --- a/evennia/commands/default/tests.py +++ b/evennia/commands/default/tests.py @@ -502,5 +502,5 @@ class TestUnconnectedCommand(CommandTest): expected = "## BEGIN INFO 1.1\nName: %s\nUptime: %s\nConnected: %d\nVersion: Evennia %s\n## END INFO" % ( settings.SERVERNAME, datetime.datetime.fromtimestamp(gametime.SERVER_START_TIME).ctime(), - SESSIONS.account_count(), utils.get_evennia_version()) + SESSIONS.account_count(), utils.get_evennia_version().replace("-","")) self.call(unloggedin.CmdUnconnectedInfo(), "", expected) From 0538160c5fe0ed0d14ca6566e4c83dd0ec6901c1 Mon Sep 17 00:00:00 2001 From: Griatch Date: Tue, 17 Apr 2018 22:49:01 +0200 Subject: [PATCH 096/208] style fix --- evennia/commands/default/tests.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/evennia/commands/default/tests.py b/evennia/commands/default/tests.py index 7f0ed17924..b2d0f58870 100644 --- a/evennia/commands/default/tests.py +++ b/evennia/commands/default/tests.py @@ -502,5 +502,5 @@ class TestUnconnectedCommand(CommandTest): expected = "## BEGIN INFO 1.1\nName: %s\nUptime: %s\nConnected: %d\nVersion: Evennia %s\n## END INFO" % ( settings.SERVERNAME, datetime.datetime.fromtimestamp(gametime.SERVER_START_TIME).ctime(), - SESSIONS.account_count(), utils.get_evennia_version().replace("-","")) + SESSIONS.account_count(), utils.get_evennia_version().replace("-", "")) self.call(unloggedin.CmdUnconnectedInfo(), "", expected) From b4edc858da135a520f67bd40869e75ee733d1de0 Mon Sep 17 00:00:00 2001 From: Griatch Date: Wed, 18 Apr 2018 07:07:03 +0200 Subject: [PATCH 097/208] Cleanup --- evennia/utils/evmenu.py | 40 ++-------------------------------------- 1 file changed, 2 insertions(+), 38 deletions(-) diff --git a/evennia/utils/evmenu.py b/evennia/utils/evmenu.py index 60373e3a5a..a2a92f5e34 100644 --- a/evennia/utils/evmenu.py +++ b/evennia/utils/evmenu.py @@ -166,7 +166,6 @@ evennia.utils.evmenu`. from __future__ import print_function import random from builtins import object, range -import re from textwrap import dedent from inspect import isfunction, getargspec @@ -1009,7 +1008,6 @@ def list_node(option_generator, select=None, pagesize=10): pagesize (int): How many options to show per page. Example: - @list_node(['foo', 'bar'], select) def node_index(caller): text = "describing the list" @@ -1043,43 +1041,10 @@ def list_node(option_generator, select=None, pagesize=10): logger.log_trace() return None -# def _input_parser(caller, raw_string, **kwargs): -# """ -# Parse which input was given, select from option_list. -# -# -# """ -# mode, selection, args = None, None, None -# available_choices = kwargs.get("available_choices", []) -# -# cmd, args = re.search(r"(^[a-zA-Z]*)\s*(.*?)$", raw_string).groups() -# -# cmd = cmd.lower().strip() -# if cmd.startswith('a') and add: -# mode = "add" -# else: -# selection, args = re.search(r"(^[0-9]*)\s*(.*?)$", args).groups() -# try: -# selection = int(selection) - 1 -# except ValueError: -# mode = "look" -# else: -# # edits are on the form 'edit -# if cmd.startswith("e") and edit: -# mode = "edit" -# elif examine: -# mode = "look" -# try: -# selection = available_choices[selection] -# except IndexError: -# caller.msg("|rInvalid index|n") -# mode = None -# -# return mode, selection, args - def _list_node(caller, raw_string, **kwargs): - option_list = option_generator(caller) if callable(option_generator) else option_generator + option_list = option_generator(caller) \ + if callable(option_generator) else option_generator npages = 0 page_index = 0 @@ -1162,7 +1127,6 @@ def list_node(option_generator, select=None, pagesize=10): options.extend(extra_options) text = text + "\n\n" + extra_text if extra_text else text - text += "\n\n(Make a choice or enter 'look ' to examine an option closer)" return text, options From e48ce5b9a3605f9763eb8ab36ce0a19bfc8ce0cd Mon Sep 17 00:00:00 2001 From: Tehom Date: Wed, 18 Apr 2018 02:26:01 -0400 Subject: [PATCH 098/208] Fix CommandTest to stop if at_pre_cmd should stop execution. --- evennia/commands/default/tests.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/evennia/commands/default/tests.py b/evennia/commands/default/tests.py index 41654077a4..8ddcee78b0 100644 --- a/evennia/commands/default/tests.py +++ b/evennia/commands/default/tests.py @@ -75,7 +75,8 @@ class CommandTest(EvenniaTest): returned_msg = "" try: receiver.msg = Mock() - cmdobj.at_pre_cmd() + if cmdobj.at_pre_cmd(): + return cmdobj.parse() ret = cmdobj.func() if isinstance(ret, types.GeneratorType): From 41a1d6a33cb5a042fb11129095357e0615fa986a Mon Sep 17 00:00:00 2001 From: Griatch Date: Wed, 18 Apr 2018 23:50:12 +0200 Subject: [PATCH 099/208] Refactor spawner to use prototype instead of metaprots --- evennia/utils/spawner.py | 454 +++++++++++++++++++-------------------- 1 file changed, 221 insertions(+), 233 deletions(-) diff --git a/evennia/utils/spawner.py b/evennia/utils/spawner.py index bbed685b5c..0bce7addd8 100644 --- a/evennia/utils/spawner.py +++ b/evennia/utils/spawner.py @@ -122,6 +122,8 @@ from evennia.utils.ansi import strip_ansi _CREATE_OBJECT_KWARGS = ("key", "location", "home", "destination") +_PROTOTYPE_META_NAMES = ("prototype_key", "prototype_desc", "prototype_tags", "prototype_locks") +_NON_CREATE_KWARGS = _CREATE_OBJECT_KWARGS + _PROTOTYPE_META_NAMES _MODULE_PROTOTYPES = {} _MODULE_PROTOTYPE_MODULES = {} _MENU_CROP_WIDTH = 15 @@ -135,24 +137,24 @@ _MENU_ATTR_LITERAL_EVAL_ERROR = ( class PermissionError(RuntimeError): pass -# storage of meta info about the prototype -MetaProto = namedtuple('MetaProto', ['key', 'desc', 'locks', 'tags', 'prototype']) for mod in settings.PROTOTYPE_MODULES: # to remove a default prototype, override it with an empty dict. # internally we store as (key, desc, locks, tags, prototype_dict) - prots = [(key, prot) for key, prot in all_from_module(mod).items() + prots = [(prototype_key, prot) for prototype_key, prot in all_from_module(mod).items() if prot and isinstance(prot, dict)] - _MODULE_PROTOTYPES.update( - {key.lower(): MetaProto( - key.lower(), - prot['prototype_desc'] if 'prototype_desc' in prot else mod, - prot['prototype_lock'] if 'prototype_lock' in prot else "use:all()", - set(make_iter( - prot['prototype_tags']) if 'prototype_tags' in prot else ["base-prototype"]), - prot) - for key, prot in prots}) + # assign module path to each prototype_key for easy reference _MODULE_PROTOTYPE_MODULES.update({tup[0]: mod for tup in prots}) + # make sure the prototype contains all meta info + for prototype_key, prot in prots: + prot.update({ + "prototype_key": prototype_key.lower(), + "prototype_desc": prot['prototype_desc'] if 'prototype_desc' in prot else mod, + "prototype_locks": prot['prototype_locks'] if 'prototype_locks' in prot else "use:all()", + "prototype_tags": set(make_iter(prot['prototype_tags']) + if 'prototype_tags' in prot else ["base-prototype"])}) + _MODULE_PROTOTYPES.update(prot) + # Prototype storage mechanisms @@ -162,24 +164,11 @@ class DbPrototype(DefaultScript): This stores a single prototype """ def at_script_creation(self): - self.key = "empty prototype" - self.desc = "A prototype" + self.key = "empty prototype" # prototype_key + self.desc = "A prototype" # prototype_desc -def build_metaproto(key='', desc='', locks='', tags=None, prototype=None): - """ - Create a metaproto from combinant parts. - - """ - if locks: - locks = (";".join(locks) if is_iter(locks) else locks) - else: - locks = [] - prototype = dict(prototype) if prototype else {} - return MetaProto(key, desc, locks, tags, dict(prototype)) - - -def save_db_prototype(caller, key, prototype, desc="", tags=None, locks="", delete=False): +def save_db_prototype(caller, prototype, key=None, desc=None, tags=None, locks="", delete=False): """ Store a prototype persistently. @@ -187,13 +176,14 @@ def save_db_prototype(caller, key, prototype, desc="", tags=None, locks="", dele caller (Account or Object): Caller aiming to store prototype. At this point the caller should have permission to 'add' new prototypes, but to edit an existing prototype, the 'edit' lock must be passed on that prototype. - key (str): Name of prototype to store. prototype (dict): Prototype dict. - desc (str, optional): Description of prototype, to use in listing. + key (str): Name of prototype to store. Will be inserted as `prototype_key` in the prototype. + desc (str, optional): Description of prototype, to use in listing. Will be inserted + as `prototype_desc` in the prototype. tags (list, optional): Tag-strings to apply to prototype. These are always - applied with the 'db_prototype' category. + applied with the 'db_prototype' category. Will be inserted as `prototype_tags`. locks (str, optional): Locks to apply to this prototype. Used locks - are 'use' and 'edit' + are 'use' and 'edit'. Will be inserted as `prototype_locks` in the prototype. delete (bool, optional): Delete an existing prototype identified by 'key'. This requires `caller` to pass the 'edit' lock of the prototype. Returns: @@ -204,21 +194,40 @@ def save_db_prototype(caller, key, prototype, desc="", tags=None, locks="", dele """ - key_orig = key - key = key.lower() - locks = locks if locks else "use:all();edit:id({}) or perm(Admin)".format(caller.id) + key_orig = key or prototype.get('prototype_key', None) + if not key_orig: + caller.msg("This prototype requires a prototype_key.") + return False + key = str(key).lower() + + # we can't edit a prototype defined in a module + if key in _MODULE_PROTOTYPES: + mod = _MODULE_PROTOTYPE_MODULES.get(key, "N/A") + raise PermissionError("{} is a read-only prototype " + "(defined as code in {}).".format(key_orig, mod)) + + prototype['prototype_key'] = key + + if desc: + desc = prototype['prototype_desc'] = desc + else: + desc = prototype.get('prototype_desc', '') + + # set up locks and check they are on a valid form + locks = locks or prototype.get( + "prototype_locks", "use:all();edit:id({}) or perm(Admin)".format(caller.id)) + prototype['prototype_locks'] = locks is_valid, err = caller.locks.validate(locks) if not is_valid: caller.msg("Lock error: {}".format(err)) return False - tags = [(tag, "db_prototype") for tag in make_iter(tags)] - - if key in _MODULE_PROTOTYPES: - mod = _MODULE_PROTOTYPE_MODULES.get(key, "N/A") - raise PermissionError("{} is a read-only prototype " - "(defined as code in {}).".format(key_orig, mod)) + if tags: + tags = [(tag, "db_prototype") for tag in make_iter(tags)] + else: + tags = prototype.get('prototype_tags', []) + prototype['prototype_tags'] = tags stored_prototype = DbPrototype.objects.filter(db_key=key) @@ -269,7 +278,7 @@ def delete_db_prototype(caller, key): return save_db_prototype(caller, key, None, delete=True) -def search_db_prototype(key=None, tags=None, return_metaprotos=False): +def search_db_prototype(key=None, tags=None, return_queryset=False): """ Find persistent (database-stored) prototypes based on key and/or tags. @@ -278,13 +287,14 @@ def search_db_prototype(key=None, tags=None, return_metaprotos=False): tags (str or list): Tag key or keys to query for. These will always be applied with the 'db_protototype' tag category. - return_metaproto (bool): Return results as metaprotos. + return_queryset (bool): Return the database queryset. Return: - matches (queryset or list): All found DbPrototypes. If `return_metaprotos` - is set, return a list of MetaProtos. + matches (queryset or list): All found DbPrototypes. If `return_queryset` + is not set, this is a list of prototype dicts. Note: - This will not include read-only prototypes defined in modules. + This does not include read-only prototypes defined in modules; use + `search_module_prototype` for those. """ if tags: @@ -297,11 +307,9 @@ def search_db_prototype(key=None, tags=None, return_metaprotos=False): if key: # exact or partial match on key matches = matches.filter(db_key=key) or matches.filter(db_key__icontains=key) - if return_metaprotos: - return [build_metaproto(match.key, match.desc, match.locks.all(), - match.tags.get(category="db_prototype", return_list=True), - match.attributes.get("prototype")) - for match in matches] + if not return_queryset: + # return prototype + return [dbprot.attributes.get("prototype", {}) for dbprot in matches] return matches @@ -314,16 +322,16 @@ def search_module_prototype(key=None, tags=None): tags (str or list): Tag key to query for. Return: - matches (list): List of MetaProto tuples that includes - prototype metadata, + matches (list): List of prototypes matching the search criterion. """ matches = {} if tags: # use tags to limit selection tagset = set(tags) - matches = {key: metaproto for key, metaproto in _MODULE_PROTOTYPES.items() - if tagset.intersection(metaproto.tags)} + matches = {prototype_key: prototype + for prototype_key, prototype in _MODULE_PROTOTYPES.items() + if tagset.intersection(prototype.get("prototype_tags", []))} else: matches = _MODULE_PROTOTYPES @@ -333,12 +341,13 @@ def search_module_prototype(key=None, tags=None): return [matches[key]] else: # fuzzy matching - return [metaproto for pkey, metaproto in matches.items() if key in pkey] + return [prototype for prototype_key, prototype in matches.items() + if key in prototype_key] else: return [match for match in matches.values()] -def search_prototype(key=None, tags=None, return_meta=True): +def search_prototype(key=None, tags=None): """ Find prototypes based on key and/or tags, or all prototypes. @@ -347,12 +356,10 @@ def search_prototype(key=None, tags=None, return_meta=True): tags (str or list): Tag key or keys to query for. These will always be applied with the 'db_protototype' tag category. - return_meta (bool): If False, only return prototype dicts, if True - return MetaProto namedtuples including prototype meta info Return: - matches (list): All found prototype dicts or MetaProtos. If no keys - or tags are given, all available prototypes/MetaProtos will be returned. + matches (list): All found prototype dicts. If no keys + or tags are given, all available prototypes will be returned. Note: The available prototypes is a combination of those supplied in @@ -363,32 +370,29 @@ def search_prototype(key=None, tags=None, return_meta=True): """ module_prototypes = search_module_prototype(key, tags) - db_prototypes = search_db_prototype(key, tags, return_metaprotos=True) + db_prototypes = search_db_prototype(key, tags) matches = db_prototypes + module_prototypes if len(matches) > 1 and key: key = key.lower() # avoid duplicates if an exact match exist between the two types - filter_matches = [mta for mta in matches if mta.key == key] + filter_matches = [mta for mta in matches + if mta.get('prototype_key') and mta['prototype_key'] == key] if filter_matches and len(filter_matches) < len(matches): matches = filter_matches - if not return_meta: - matches = [mta.prototype for mta in matches] - return matches -def get_protparents(): +def get_protparent_dict(): """ - Get prototype parents. These are a combination of meta-key and prototype-dict and are used when - a prototype refers to another parent-prototype. + Get prototype parents. + + Returns: + parent_dict (dict): A mapping {prototype_key: prototype} for all available prototypes. """ - # get all prototypes - metaprotos = search_prototype(return_meta=True) - # organize by key - return {metaproto.key: metaproto.prototype for metaproto in metaprotos} + return {prototype['prototype_key']: prototype for prototype in search_prototype()} def list_prototypes(caller, key=None, tags=None, show_non_use=False, show_non_edit=True): @@ -410,35 +414,29 @@ def list_prototypes(caller, key=None, tags=None, show_non_use=False, show_non_ed tags = [tag for tag in make_iter(tags) if tag] # get metaprotos for readonly and db-based prototypes - metaprotos = search_module_prototype(key, tags) - metaprotos += search_db_prototype(key, tags, return_metaprotos=True) + prototypes = search_prototype(key, tags) # get use-permissions of readonly attributes (edit is always False) - prototypes = [ - (metaproto.key, - metaproto.desc, - ("{}/N".format('Y' - if caller.locks.check_lockstring( - caller, - metaproto.locks, - access_type='use') else 'N')), - ",".join(metaproto.tags)) - for metaproto in sorted(metaprotos, key=lambda o: o.key)] + display_tuples = [] + for prototype in sorted(prototypes, key=lambda d: d['prototype_key']): + lock_use = caller.locks.check_lockstring(caller, prototype['locks'], access_type='use') + if not show_non_use and not lock_use: + continue + lock_edit = caller.locks.check_lockstring(caller, prototype['locks'], access_type='edit') + if not show_non_edit and not lock_edit: + continue + display_tuples.append( + (prototype.get('prototype_key', '', + prototype['prototype_desc', ''], + "{}/{}".format('Y' if lock_use else 'N', 'Y' if lock_edit else 'N'), + ",".join(prototype.get('prototype_tags', []))))) - if not prototypes: - return None - - if not show_non_use: - prototypes = [metaproto for metaproto in prototypes if metaproto[2].split("/", 1)[0] == 'Y'] - if not show_non_edit: - prototypes = [metaproto for metaproto in prototypes if metaproto[2].split("/", 1)[1] == 'Y'] - - if not prototypes: + if not display_tuples: return None table = [] - for i in range(len(prototypes[0])): - table.append([str(metaproto[i]) for metaproto in prototypes]) + for i in range(len(display_tuples[0])): + table.append([str(display_tuple[i]) for display_tuple in display_tuples]) table = EvTable("Key", "Desc", "Use/Edit", "Tags", table=table, crop=True, width=78) table.reformat_column(0, width=28) table.reformat_column(1, width=40) @@ -447,22 +445,26 @@ def list_prototypes(caller, key=None, tags=None, show_non_use=False, show_non_ed return table -def metaproto_to_str(metaproto): +def prototype_to_str(prototype): """ - Format a metaproto to a nice string representation. + Format a prototype to a nice string representation. Args: metaproto (NamedTuple): Represents the prototype. """ + header = ( "|cprototype key:|n {}, |ctags:|n {}, |clocks:|n {} \n" "|cdesc:|n {} \n|cprototype:|n ".format( - metaproto.key, ", ".join(metaproto.tags), - metaproto.locks, metaproto.desc)) - prototype = ("{{\n {} \n}}".format("\n ".join("{!r}: {!r},".format(key, value) - for key, value in - sorted(metaproto.prototype.items())).rstrip(","))) - return header + prototype + prototype['prototype_key'], + ", ".join(prototype['prototype_tags']), + prototype['prototype_locks'], + prototype['prototype_desc'])) + proto = ("{{\n {} \n}}".format( + "\n ".join( + "{!r}: {!r},".format(key, value) for key, value in + sorted(prototype.items()) if key not in _PROTOTYPE_META_NAMES)).rstrip(",")) + return header + proto # Spawner mechanism @@ -487,10 +489,12 @@ def validate_prototype(prototype, protkey=None, protparents=None, _visited=None) """ if not protparents: - protparents = get_protparents() + protparents = get_protparent_dict() if _visited is None: _visited = [] - protkey = protkey.lower() if protkey is not None else None + protkey = protkey or prototype.get('prototype_key', None) + + protkey = protkey.lower() or prototype.get('prototype_key', None) assert isinstance(prototype, dict) @@ -537,8 +541,8 @@ def _batch_create_object(*objparams): so make sure the spawned Typeclass works before using this! Args: - objsparams (tuple): Parameters for the respective creation/add - handlers in the following order: + objsparams (tuple): Each paremter tuple will create one object instance using the parameters within. + The parameters should be given in the following order: - `create_kwargs` (dict): For use as new_obj = `ObjectDB(**create_kwargs)`. - `permissions` (str): Permission string used with `new_obj.batch_add(permission)`. - `lockstring` (str): Lockstring used with `new_obj.locks.add(lockstring)`. @@ -555,9 +559,6 @@ def _batch_create_object(*objparams): (the newly created object) available in the namespace. Execution will happend after all other properties have been assigned and is intended for calling custom handlers etc. - for the respective creation/add handlers in the following - order: (create_kwargs, permissions, locks, aliases, nattributes, - attributes, tags, execs) Returns: objects (list): A list of created objects @@ -664,6 +665,10 @@ def spawn(*prototypes, **kwargs): alias_string = aliasval() if callable(aliasval) else aliasval tagval = prot.pop("tags", []) tags = tagval() if callable(tagval) else tagval + + # we make sure to add a tag identifying which prototype created this object + # tags.append(()) + attrval = prot.pop("attrs", []) attributes = attrval() if callable(tagval) else attrval @@ -676,9 +681,9 @@ def spawn(*prototypes, **kwargs): # the rest are attributes simple_attributes = [(key, value()) if callable(value) else (key, value) - for key, value in prot.items() if not key.startswith("ndb_")] + for key, value in prot.items() if not (key.startswith("ndb_"))] attributes = attributes + simple_attributes - attributes = [tup for tup in attributes if not tup[0] in _CREATE_OBJECT_KWARGS] + attributes = [tup for tup in attributes if not tup[0] in _NON_CREATE_KWARGS] # pack for call into _batch_create_object objsparams.append((create_kwargs, permission_string, lock_string, @@ -695,34 +700,30 @@ def spawn(*prototypes, **kwargs): # Helper functions -def _get_menu_metaprot(caller): +def _get_menu_prototype(caller): - metaproto = None - if hasattr(caller.ndb._menutree, "olc_metaprot"): - metaproto = caller.ndb._menutree.olc_metaprot - if not metaproto: - metaproto = build_metaproto(None, '', [], [], None) - caller.ndb._menutree.olc_metaprot = metaproto + prototype = None + if hasattr(caller.ndb._menutree, "olc_prototype"): + prototype = caller.ndb._menutree.olc_prototype + if not prototype: + caller.ndb._menutree.olc_prototype = {} caller.ndb._menutree.olc_new = True - return metaproto + return prototype def _is_new_prototype(caller): return hasattr(caller.ndb._menutree, "olc_new") -def _set_menu_metaprot(caller, field, value): - metaprot = _get_menu_metaprot(caller) - kwargs = dict(metaprot.__dict__) - kwargs[field] = value - caller.ndb._menutree.olc_metaprot = build_metaproto(**kwargs) +def _set_menu_prototype(caller, field, value): + prototype = _get_menu_prototype(caller) + prototype[field] = value + caller.ndb._menutree.olc_prototype = prototype -def _format_property(key, required=False, metaprot=None, prototype=None, cropper=None): +def _format_property(key, required=False, prototype=None, cropper=None): key = key.lower() - if metaprot is not None: - prop = getattr(metaprot, key) or '' - elif prototype is not None: + if prototype is not None: prop = prototype.get(key, '') out = prop @@ -753,14 +754,11 @@ def _set_property(caller, raw_string, **kwargs): next_node (str): Next node to go to. """ - prop = kwargs.get("prop", "meta_key") + prop = kwargs.get("prop", "prototype_key") processor = kwargs.get("processor", None) next_node = kwargs.get("next_node", "node_index") propname_low = prop.strip().lower() - meta = propname_low.startswith("meta_") - if meta: - propname_low = propname_low[5:] if callable(processor): try: @@ -776,23 +774,17 @@ def _set_property(caller, raw_string, **kwargs): if not value: return next_node - if meta: - _set_menu_metaprot(caller, propname_low, value) - else: - metaprot = _get_menu_metaprot(caller) - prototype = metaprot.prototype - prototype[propname_low] = value + prototype = _get_menu_prototype(caller) - # typeclass and prototype can't co-exist - if propname_low == "typeclass": - prototype.pop("prototype", None) - if propname_low == "prototype": - prototype.pop("typeclass", None) + # typeclass and prototype can't co-exist + if propname_low == "typeclass": + prototype.pop("prototype", None) + if propname_low == "prototype": + prototype.pop("typeclass", None) - _set_menu_metaprot(caller, "prototype", prototype) + caller.ndb._menutree.olc_prototype = prototype - caller.msg("Set {prop} to '{value}'.".format( - prop=prop.replace("_", "-").capitalize(), value=str(value))) + caller.msg("Set {prop} to '{value}'.".format(prop, value=str(value))) return next_node @@ -829,8 +821,7 @@ def _path_cropper(pythonpath): # Menu nodes def node_index(caller): - metaprot = _get_menu_metaprot(caller) - prototype = metaprot.prototype + prototype = _get_menu_prototype(caller) text = ("|c --- Prototype wizard --- |n\n\n" "Define the |yproperties|n of the prototype. All prototype values can be " @@ -841,10 +832,9 @@ def node_index(caller): "others later.\n\n(make choice; q to abort. If unsure, start from 1.)") options = [] - # The meta-key goes first options.append( - {"desc": "|WMeta-Key|n|n{}".format(_format_property("Key", True, metaprot, None)), - "goto": "node_meta_key"}) + {"desc": "|WPrototype-Key|n|n{}".format(_format_property("Key", True, prototype, None)), + "goto": "node_prototype_key"}) for key in ('Prototype', 'Typeclass', 'Key', 'Aliases', 'Attrs', 'Tags', 'Locks', 'Permissions', 'Location', 'Home', 'Destination'): required = False @@ -860,20 +850,20 @@ def node_index(caller): required = False for key in ('Desc', 'Tags', 'Locks'): options.append( - {"desc": "|WMeta-{}|n|n{}".format(key, _format_property(key, required, metaprot, None)), - "goto": "node_meta_{}".format(key.lower())}) + {"desc": "|WPrototype-{}|n|n{}".format(key, _format_property(key, required, prototype, None)), + "goto": "node_prototype_{}".format(key.lower())}) return text, options def node_validate_prototype(caller, raw_string, **kwargs): - metaprot = _get_menu_metaprot(caller) + prototype = _get_menu_prototype(caller) - txt = metaproto_to_str(metaprot) + txt = prototype_to_str(prototype) errors = "\n\n|g No validation errors found.|n (but errors could still happen at spawn-time)" try: # validate, don't spawn - spawn(metaprot.prototype, return_prototypes=True) + spawn(prototype, return_prototypes=True) except RuntimeError as err: errors = "\n\n|rError: {}|n".format(err) text = (txt + errors) @@ -883,42 +873,43 @@ def node_validate_prototype(caller, raw_string, **kwargs): return text, options -def _check_meta_key(caller, key): - old_metaprot = search_prototype(key) +def _check_prototype_key(caller, key): + old_prototype = search_prototype(key) olc_new = _is_new_prototype(caller) key = key.strip().lower() - if old_metaprot: + if old_prototype: # we are starting a new prototype that matches an existing - if not caller.locks.check_lockstring(caller, old_metaprot.locks, access_type='edit'): - # return to the node_meta_key to try another key + if not caller.locks.check_lockstring( + caller, old_prototype['prototype_locks'], access_type='edit'): + # return to the node_prototype_key to try another key caller.msg("Prototype '{key}' already exists and you don't " "have permission to edit it.".format(key=key)) - return "node_meta_key" + return "node_prototype_key" elif olc_new: # we are selecting an existing prototype to edit. Reset to index. del caller.ndb._menutree.olc_new - caller.ndb._menutree.olc_metaprot = old_metaprot + caller.ndb._menutree.olc_prototype = old_prototype caller.msg("Prototype already exists. Reloading.") return "node_index" - return _set_property(caller, key, prop='meta_key', next_node="node_prototype") + return _set_property(caller, key, prop='prototype_key', next_node="node_prototype") -def node_meta_key(caller): - metaprot = _get_menu_metaprot(caller) +def node_prototype_key(caller): + prototype = _get_menu_prototype(caller) text = ["The prototype name, or |wMeta-Key|n, uniquely identifies the prototype. " "It is used to find and use the prototype to spawn new entities. " "It is not case sensitive."] - old_key = metaprot.key + old_key = prototype['prototype_key'] if old_key: text.append("Current key is '|w{key}|n'".format(key=old_key)) else: text.append("The key is currently unset.") text.append("Enter text or make a choice (q for quit)") text = "\n\n".join(text) - options = _wizard_options("meta_key", "index", "prototype") + options = _wizard_options("prototype_key", "index", "prototype") options.append({"key": "_default", - "goto": _check_meta_key}) + "goto": _check_prototype_key}) return text, options @@ -927,9 +918,9 @@ def _all_prototypes(caller): def _prototype_examine(caller, prototype_name): - metaprot = search_prototype(key=prototype_name) - if metaprot: - caller.msg(metaproto_to_str(metaprot[0])) + prototypes = search_prototype(key=prototype_name) + if prototypes: + caller.msg(prototype_to_str(prototypes[0])) caller.msg("Prototype not registered.") return None @@ -942,17 +933,22 @@ def _prototype_select(caller, prototype): @list_node(_all_prototypes, _prototype_select) def node_prototype(caller): - metaprot = _get_menu_metaprot(caller) - prot = metaprot.prototype - prototype = prot.get("prototype") + prototype = _get_menu_prototype(caller) - text = ["Set the prototype's parent |yPrototype|n. If this is unset, Typeclass will be used."] - if prototype: - text.append("Current prototype is |y{prototype}|n.".format(prototype=prototype)) + prot_parent_key = prototype.get('prototype') + + text = ["Set the prototype's |yParent Prototype|n. If this is unset, Typeclass will be used."] + if prot_parent_key: + prot_parent = search_prototype(prot_parent_key) + if prot_parent: + text.append("Current parent prototype is {}:\n{}".format(prototype_to_str(prot_parent))) + else: + text.append("Current parent prototype |r{prototype}|n " + "does not appear to exist.".format(prot_parent_key)) else: text.append("Parent prototype is not set") text = "\n\n".join(text) - options = _wizard_options("prototype", "meta_key", "typeclass", color="|W") + options = _wizard_options("prototype", "prototype_key", "typeclass", color="|W") options.append({"key": "_default", "goto": _prototype_examine}) @@ -961,7 +957,6 @@ def node_prototype(caller): def _all_typeclasses(caller): return list(sorted(get_all_typeclasses().keys())) - # return list(sorted(get_all_typeclasses(parent="evennia.objects.objects.DefaultObject").keys())) def _typeclass_examine(caller, typeclass_path): @@ -994,9 +989,8 @@ def _typeclass_select(caller, typeclass): @list_node(_all_typeclasses, _typeclass_select) def node_typeclass(caller): - metaprot = _get_menu_metaprot(caller) - prot = metaprot.prototype - typeclass = prot.get("typeclass") + prototype = _get_menu_prototype(caller) + typeclass = prototype.get("typeclass") text = ["Set the typeclass's parent |yTypeclass|n."] if typeclass: @@ -1012,9 +1006,8 @@ def node_typeclass(caller): def node_key(caller): - metaprot = _get_menu_metaprot(caller) - prot = metaprot.prototype - key = prot.get("key") + prototype = _get_menu_prototype(caller) + key = prototype.get("key") text = ["Set the prototype's |yKey|n. This will retain case sensitivity."] if key: @@ -1032,9 +1025,8 @@ def node_key(caller): def node_aliases(caller): - metaprot = _get_menu_metaprot(caller) - prot = metaprot.prototype - aliases = prot.get("aliases") + prototype = _get_menu_prototype(caller) + aliases = prototype.get("aliases") text = ["Set the prototype's |yAliases|n. Separate multiple aliases with commas. " "ill retain case sensitivity."] @@ -1053,8 +1045,8 @@ def node_aliases(caller): def _caller_attrs(caller): - metaprot = _get_menu_metaprot(caller) - attrs = metaprot.prototype.get("attrs", []) + prototype = _get_menu_prototype(caller) + attrs = prototype.get("attrs", []) return attrs @@ -1078,10 +1070,9 @@ def _attrparse(caller, attr_string): def _add_attr(caller, attr_string, **kwargs): attrname, value = _attrparse(caller, attr_string) if attrname: - metaprot = _get_menu_metaprot(caller) - prot = metaprot.prototype + prot = _get_menu_prototype(caller) prot['attrs'][attrname] = value - _set_menu_metaprot(caller, "prototype", prot) + _set_menu_prototype(caller, "prototype", prot) text = "Added" else: text = "Attribute must be given as 'attrname = ' where uses valid Python." @@ -1093,8 +1084,7 @@ def _add_attr(caller, attr_string, **kwargs): def _edit_attr(caller, attrname, new_value, **kwargs): attrname, value = _attrparse("{}={}".format(caller, attrname, new_value)) if attrname: - metaprot = _get_menu_metaprot(caller) - prot = metaprot.prototype + prot = _get_menu_prototype(caller) prot['attrs'][attrname] = value text = "Edited Attribute {} = {}".format(attrname, value) else: @@ -1105,16 +1095,14 @@ def _edit_attr(caller, attrname, new_value, **kwargs): def _examine_attr(caller, selection): - metaprot = _get_menu_metaprot(caller) - prot = metaprot.prototype + prot = _get_menu_prototype(caller) value = prot['attrs'][selection] return "Attribute {} = {}".format(selection, value) @list_node(_caller_attrs) def node_attrs(caller): - metaprot = _get_menu_metaprot(caller) - prot = metaprot.prototype + prot = _get_menu_prototype(caller) attrs = prot.get("attrs") text = ["Set the prototype's |yAttributes|n. Separate multiple attrs with commas. " @@ -1134,7 +1122,7 @@ def node_attrs(caller): def _caller_tags(caller): - metaprot = _get_menu_metaprot(caller) + metaprot = _get_menu_prototype(caller) prot = metaprot.prototype tags = prot.get("tags") return tags @@ -1142,7 +1130,7 @@ def _caller_tags(caller): def _add_tag(caller, tag, **kwargs): tag = tag.strip().lower() - metaprot = _get_menu_metaprot(caller) + metaprot = _get_menu_prototype(caller) prot = metaprot.prototype tags = prot.get('tags', []) if tags: @@ -1151,7 +1139,7 @@ def _add_tag(caller, tag, **kwargs): else: tags = [tag] prot['tags'] = tags - _set_menu_metaprot(caller, "prototype", prot) + _set_menu_prototype(caller, "prototype", prot) text = kwargs.get("text") if not text: text = "Added tag {}. (return to continue)".format(tag) @@ -1161,7 +1149,7 @@ def _add_tag(caller, tag, **kwargs): def _edit_tag(caller, old_tag, new_tag, **kwargs): - metaprot = _get_menu_metaprot(caller) + metaprot = _get_menu_prototype(caller) prototype = metaprot.prototype tags = prototype.get('tags', []) @@ -1169,7 +1157,7 @@ def _edit_tag(caller, old_tag, new_tag, **kwargs): new_tag = new_tag.strip().lower() tags[tags.index(old_tag)] = new_tag prototype['tags'] = tags - _set_menu_metaprot(caller, 'prototype', prototype) + _set_menu_prototype(caller, 'prototype', prototype) text = kwargs.get('text') if not text: @@ -1187,7 +1175,7 @@ def node_tags(caller): def node_locks(caller): - metaprot = _get_menu_metaprot(caller) + metaprot = _get_menu_prototype(caller) prot = metaprot.prototype locks = prot.get("locks") @@ -1208,7 +1196,7 @@ def node_locks(caller): def node_permissions(caller): - metaprot = _get_menu_metaprot(caller) + metaprot = _get_menu_prototype(caller) prot = metaprot.prototype permissions = prot.get("permissions") @@ -1229,7 +1217,7 @@ def node_permissions(caller): def node_location(caller): - metaprot = _get_menu_metaprot(caller) + metaprot = _get_menu_prototype(caller) prot = metaprot.prototype location = prot.get("location") @@ -1249,7 +1237,7 @@ def node_location(caller): def node_home(caller): - metaprot = _get_menu_metaprot(caller) + metaprot = _get_menu_prototype(caller) prot = metaprot.prototype home = prot.get("home") @@ -1269,7 +1257,7 @@ def node_home(caller): def node_destination(caller): - metaprot = _get_menu_metaprot(caller) + metaprot = _get_menu_prototype(caller) prot = metaprot.prototype dest = prot.get("dest") @@ -1279,18 +1267,18 @@ def node_destination(caller): else: text.append("No destination is set (default).") text = "\n\n".join(text) - options = _wizard_options("destination", "home", "meta_desc") + options = _wizard_options("destination", "home", "prototype_desc") options.append({"key": "_default", "goto": (_set_property, dict(prop="dest", processor=lambda s: s.strip(), - next_node="node_meta_desc"))}) + next_node="node_prototype_desc"))}) return text, options -def node_meta_desc(caller): +def node_prototype_desc(caller): - metaprot = _get_menu_metaprot(caller) + metaprot = _get_menu_prototype(caller) text = ["The |wMeta-Description|n briefly describes the prototype for viewing in listings."] desc = metaprot.desc @@ -1299,18 +1287,18 @@ def node_meta_desc(caller): else: text.append("Description is currently unset.") text = "\n\n".join(text) - options = _wizard_options("meta_desc", "meta_key", "meta_tags") + options = _wizard_options("prototype_desc", "prototype_key", "prototype_tags") options.append({"key": "_default", "goto": (_set_property, - dict(prop='meta_desc', + dict(prop='prototype_desc', processor=lambda s: s.strip(), - next_node="node_meta_tags"))}) + next_node="node_prototype_tags"))}) return text, options -def node_meta_tags(caller): - metaprot = _get_menu_metaprot(caller) +def node_prototype_tags(caller): + metaprot = _get_menu_prototype(caller) text = ["|wMeta-Tags|n can be used to classify and find prototypes. Tags are case-insensitive. " "Separate multiple by tags by commas."] tags = metaprot.tags @@ -1320,18 +1308,18 @@ def node_meta_tags(caller): else: text.append("No tags are currently set.") text = "\n\n".join(text) - options = _wizard_options("meta_tags", "meta_desc", "meta_locks") + options = _wizard_options("prototype_tags", "prototype_desc", "prototype_locks") options.append({"key": "_default", "goto": (_set_property, - dict(prop="meta_tags", + dict(prop="prototype_tags", processor=lambda s: [ str(part.strip().lower()) for part in s.split(",")], - next_node="node_meta_locks"))}) + next_node="node_prototype_locks"))}) return text, options -def node_meta_locks(caller): - metaprot = _get_menu_metaprot(caller) +def node_prototype_locks(caller): + metaprot = _get_menu_prototype(caller) text = ["Set |wMeta-Locks|n on the prototype. There are two valid lock types: " "'edit' (who can edit the prototype) and 'use' (who can apply the prototype)\n" "(If you are unsure, leave as default.)"] @@ -1342,10 +1330,10 @@ def node_meta_locks(caller): text.append("Lock unset - if not changed the default lockstring will be set as\n" " |w'use:all(); edit:id({dbref}) or perm(Admin)'|n".format(dbref=caller.id)) text = "\n\n".join(text) - options = _wizard_options("meta_locks", "meta_tags", "index") + options = _wizard_options("prototype_locks", "prototype_tags", "index") options.append({"key": "_default", "goto": (_set_property, - dict(prop="meta_locks", + dict(prop="prototype_locks", processor=lambda s: s.strip().lower(), next_node="node_index"))}) return text, options @@ -1392,7 +1380,7 @@ def start_olc(caller, session=None, metaproto=None): """ menudata = {"node_index": node_index, "node_validate_prototype": node_validate_prototype, - "node_meta_key": node_meta_key, + "node_prototype_key": node_prototype_key, "node_prototype": node_prototype, "node_typeclass": node_typeclass, "node_key": node_key, @@ -1404,11 +1392,11 @@ def start_olc(caller, session=None, metaproto=None): "node_location": node_location, "node_home": node_home, "node_destination": node_destination, - "node_meta_desc": node_meta_desc, - "node_meta_tags": node_meta_tags, - "node_meta_locks": node_meta_locks, + "node_prototype_desc": node_prototype_desc, + "node_prototype_tags": node_prototype_tags, + "node_prototype_locks": node_prototype_locks, } - OLCMenu(caller, menudata, startnode='node_index', session=session, olc_metaprot=metaproto) + OLCMenu(caller, menudata, startnode='node_index', session=session, olc_prototype=metaproto) # Testing From f3796ea6331c1dea999e0bd2e252810bde34d1a3 Mon Sep 17 00:00:00 2001 From: Griatch Date: Thu, 19 Apr 2018 22:23:24 +0200 Subject: [PATCH 100/208] Clean out metaprots, only use prototypes --- evennia/commands/default/building.py | 47 +++++++------- evennia/utils/spawner.py | 93 +++++++++++++--------------- 2 files changed, 67 insertions(+), 73 deletions(-) diff --git a/evennia/commands/default/building.py b/evennia/commands/default/building.py index 77bdb619f1..c593a6376d 100644 --- a/evennia/commands/default/building.py +++ b/evennia/commands/default/building.py @@ -14,9 +14,9 @@ from evennia.utils.utils import inherits_from, class_from_module, get_all_typecl from evennia.utils.eveditor import EvEditor from evennia.utils.evmore import EvMore from evennia.utils.spawner import (spawn, search_prototype, list_prototypes, - save_db_prototype, build_metaproto, validate_prototype, + save_db_prototype, validate_prototype, delete_db_prototype, PermissionError, start_olc, - metaproto_to_str) + prototype_to_str) from evennia.utils.ansi import raw COMMAND_DEFAULT_CLASS = class_from_module(settings.COMMAND_DEFAULT_CLASS) @@ -2885,12 +2885,12 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): return return prototype - def _search_show_prototype(query, metaprots=None): + def _search_show_prototype(query, prototypes=None): # prototype detail - if not metaprots: - metaprots = search_prototype(key=query, return_meta=True) - if metaprots: - return "\n".join(metaproto_to_str(metaprot) for metaprot in metaprots) + if not prototypes: + prototypes = search_prototype(key=query) + if prototypes: + return "\n".join(prototype_to_str(prot) for prot in prototypes) else: return False @@ -2898,18 +2898,18 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): if self.cmdstring == "olc" or 'menu' in self.switches or 'olc' in self.switches: # OLC menu mode - metaprot = None + prototype = None if self.lhs: key = self.lhs - metaprot = search_prototype(key=key, return_meta=True) - if len(metaprot) > 1: + prototype = search_prototype(key=key, return_meta=True) + if len(prototype) > 1: caller.msg("More than one match for {}:\n{}".format( - key, "\n".join(mproto.key for mproto in metaprot))) + key, "\n".join(proto.get('prototype_key', '') for proto in prototype))) return - elif metaprot: + elif prototype: # one match - metaprot = metaprot[0] - start_olc(caller, session=self.session, metaproto=metaprot) + prototype = prototype[0] + start_olc(caller, session=self.session, prototype=prototype) return if 'search' in self.switches: @@ -3005,8 +3005,7 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): return # present prototype to save - new_matchstring = _search_show_prototype( - "", metaprots=[build_metaproto(key, desc, [lockstring], tags, prototype)]) + new_matchstring = _search_show_prototype("", prototypes=[prototype]) string = "|yCreating new prototype:|n\n{}".format(new_matchstring) question = "\nDo you want to continue saving? [Y]/N" @@ -3056,21 +3055,21 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): if isinstance(prototype, basestring): # A prototype key we are looking to apply key = prototype - metaprotos = search_prototype(prototype) - nprots = len(metaprotos) - if not metaprotos: + prototypes = search_prototype(prototype) + nprots = len(prototypes) + if not prototypes: caller.msg("No prototype named '%s'." % prototype) return elif nprots > 1: caller.msg("Found {} prototypes matching '{}':\n {}".format( - nprots, prototype, ", ".join(metaproto.key for metaproto in metaprotos))) + nprots, prototype, ", ".join(prot.get('prototype_key', '') + for proto in prototypes))) return - # we have a metaprot, check access - metaproto = metaprotos[0] - if not caller.locks.check_lockstring(caller, metaproto.locks, access_type='use'): + # we have a prototype, check access + prototype = prototypes[0] + if not caller.locks.check_lockstring(caller, prototype.get('prototype_locks', ''), access_type='use'): caller.msg("You don't have access to use this prototype.") return - prototype = metaproto.prototype if "noloc" not in self.switches and "location" not in prototype: prototype["location"] = self.caller.location diff --git a/evennia/utils/spawner.py b/evennia/utils/spawner.py index 0bce7addd8..1916c2210e 100644 --- a/evennia/utils/spawner.py +++ b/evennia/utils/spawner.py @@ -144,7 +144,7 @@ for mod in settings.PROTOTYPE_MODULES: prots = [(prototype_key, prot) for prototype_key, prot in all_from_module(mod).items() if prot and isinstance(prot, dict)] # assign module path to each prototype_key for easy reference - _MODULE_PROTOTYPE_MODULES.update({tup[0]: mod for tup in prots}) + _MODULE_PROTOTYPE_MODULES.update({prototype_key: mod for prototype_key, _ in prots}) # make sure the prototype contains all meta info for prototype_key, prot in prots: prot.update({ @@ -153,7 +153,7 @@ for mod in settings.PROTOTYPE_MODULES: "prototype_locks": prot['prototype_locks'] if 'prototype_locks' in prot else "use:all()", "prototype_tags": set(make_iter(prot['prototype_tags']) if 'prototype_tags' in prot else ["base-prototype"])}) - _MODULE_PROTOTYPES.update(prot) + _MODULE_PROTOTYPES[prototype_key] = prot # Prototype storage mechanisms @@ -413,23 +413,25 @@ def list_prototypes(caller, key=None, tags=None, show_non_use=False, show_non_ed # this allows us to pass lists of empty strings tags = [tag for tag in make_iter(tags) if tag] - # get metaprotos for readonly and db-based prototypes + # get prototypes for readonly and db-based prototypes prototypes = search_prototype(key, tags) # get use-permissions of readonly attributes (edit is always False) display_tuples = [] - for prototype in sorted(prototypes, key=lambda d: d['prototype_key']): - lock_use = caller.locks.check_lockstring(caller, prototype['locks'], access_type='use') + for prototype in sorted(prototypes, key=lambda d: d.get('prototype_key', '')): + lock_use = caller.locks.check_lockstring( + caller, prototype.get('prototype_locks', ''), access_type='use') if not show_non_use and not lock_use: continue - lock_edit = caller.locks.check_lockstring(caller, prototype['locks'], access_type='edit') + lock_edit = caller.locks.check_lockstring( + caller, prototype.get('prototype_locks', ''), access_type='edit') if not show_non_edit and not lock_edit: continue display_tuples.append( - (prototype.get('prototype_key', '', - prototype['prototype_desc', ''], + (prototype.get('prototype_key', ''), + prototype.get('prototype_desc', ''), "{}/{}".format('Y' if lock_use else 'N', 'Y' if lock_edit else 'N'), - ",".join(prototype.get('prototype_tags', []))))) + ",".join(prototype.get('prototype_tags', [])))) if not display_tuples: return None @@ -450,7 +452,7 @@ def prototype_to_str(prototype): Format a prototype to a nice string representation. Args: - metaproto (NamedTuple): Represents the prototype. + prototype (dict): The prototype. """ header = ( @@ -706,7 +708,7 @@ def _get_menu_prototype(caller): if hasattr(caller.ndb._menutree, "olc_prototype"): prototype = caller.ndb._menutree.olc_prototype if not prototype: - caller.ndb._menutree.olc_prototype = {} + caller.ndb._menutree.olc_prototype = prototype = {} caller.ndb._menutree.olc_new = True return prototype @@ -721,10 +723,10 @@ def _set_menu_prototype(caller, field, value): caller.ndb._menutree.olc_prototype = prototype -def _format_property(key, required=False, prototype=None, cropper=None): - key = key.lower() +def _format_property(prop, required=False, prototype=None, cropper=None): + if prototype is not None: - prop = prototype.get(key, '') + prop = prototype.get(prop, '') out = prop if callable(prop): @@ -845,7 +847,7 @@ def node_index(caller): cropper = _path_cropper options.append( {"desc": "|w{}|n{}".format( - key, _format_property(key, required, None, prototype, cropper=cropper)), + key, _format_property(key, required, prototype, cropper=cropper)), "goto": "node_{}".format(key.lower())}) required = False for key in ('Desc', 'Tags', 'Locks'): @@ -900,7 +902,7 @@ def node_prototype_key(caller): text = ["The prototype name, or |wMeta-Key|n, uniquely identifies the prototype. " "It is used to find and use the prototype to spawn new entities. " "It is not case sensitive."] - old_key = prototype['prototype_key'] + old_key = prototype.get('prototype_key', None) if old_key: text.append("Current key is '|w{key}|n'".format(key=old_key)) else: @@ -914,7 +916,8 @@ def node_prototype_key(caller): def _all_prototypes(caller): - return [mproto.key for mproto in search_prototype()] + return [prototype["prototype_key"] + for prototype in search_prototype() if "prototype_key" in prototype] def _prototype_examine(caller, prototype_name): @@ -1122,17 +1125,15 @@ def node_attrs(caller): def _caller_tags(caller): - metaprot = _get_menu_prototype(caller) - prot = metaprot.prototype - tags = prot.get("tags") + prototype = _get_menu_prototype(caller) + tags = prototype.get("tags") return tags def _add_tag(caller, tag, **kwargs): tag = tag.strip().lower() - metaprot = _get_menu_prototype(caller) - prot = metaprot.prototype - tags = prot.get('tags', []) + prototype = _get_menu_prototype(caller) + tags = prototype.get('tags', []) if tags: if tag not in tags: tags.append(tag) @@ -1149,8 +1150,7 @@ def _add_tag(caller, tag, **kwargs): def _edit_tag(caller, old_tag, new_tag, **kwargs): - metaprot = _get_menu_prototype(caller) - prototype = metaprot.prototype + prototype = _get_menu_prototype(caller) tags = prototype.get('tags', []) old_tag = old_tag.strip().lower() @@ -1175,9 +1175,8 @@ def node_tags(caller): def node_locks(caller): - metaprot = _get_menu_prototype(caller) - prot = metaprot.prototype - locks = prot.get("locks") + prototype = _get_menu_prototype(caller) + locks = prototype.get("locks") text = ["Set the prototype's |yLock string|n. Separate multiple locks with semi-colons. " "Will retain case sensitivity."] @@ -1196,9 +1195,8 @@ def node_locks(caller): def node_permissions(caller): - metaprot = _get_menu_prototype(caller) - prot = metaprot.prototype - permissions = prot.get("permissions") + prototype = _get_menu_prototype(caller) + permissions = prototype.get("permissions") text = ["Set the prototype's |yPermissions|n. Separate multiple permissions with commas. " "Will retain case sensitivity."] @@ -1217,9 +1215,8 @@ def node_permissions(caller): def node_location(caller): - metaprot = _get_menu_prototype(caller) - prot = metaprot.prototype - location = prot.get("location") + prototype = _get_menu_prototype(caller) + location = prototype.get("location") text = ["Set the prototype's |yLocation|n"] if location: @@ -1237,9 +1234,8 @@ def node_location(caller): def node_home(caller): - metaprot = _get_menu_prototype(caller) - prot = metaprot.prototype - home = prot.get("home") + prototype = _get_menu_prototype(caller) + home = prototype.get("home") text = ["Set the prototype's |yHome location|n"] if home: @@ -1257,9 +1253,8 @@ def node_home(caller): def node_destination(caller): - metaprot = _get_menu_prototype(caller) - prot = metaprot.prototype - dest = prot.get("dest") + prototype = _get_menu_prototype(caller) + dest = prototype.get("dest") text = ["Set the prototype's |yDestination|n. This is usually only used for Exits."] if dest: @@ -1278,9 +1273,9 @@ def node_destination(caller): def node_prototype_desc(caller): - metaprot = _get_menu_prototype(caller) + prototype = _get_menu_prototype(caller) text = ["The |wMeta-Description|n briefly describes the prototype for viewing in listings."] - desc = metaprot.desc + desc = prototype.get("prototype_desc", None) if desc: text.append("The current meta desc is:\n\"|w{desc}|n\"".format(desc=desc)) @@ -1298,10 +1293,10 @@ def node_prototype_desc(caller): def node_prototype_tags(caller): - metaprot = _get_menu_prototype(caller) + prototype = _get_menu_prototype(caller) text = ["|wMeta-Tags|n can be used to classify and find prototypes. Tags are case-insensitive. " "Separate multiple by tags by commas."] - tags = metaprot.tags + tags = prototype.get('prototype_tags', []) if tags: text.append("The current tags are:\n|w{tags}|n".format(tags=tags)) @@ -1319,11 +1314,11 @@ def node_prototype_tags(caller): def node_prototype_locks(caller): - metaprot = _get_menu_prototype(caller) + prototype = _get_menu_prototype(caller) text = ["Set |wMeta-Locks|n on the prototype. There are two valid lock types: " "'edit' (who can edit the prototype) and 'use' (who can apply the prototype)\n" "(If you are unsure, leave as default.)"] - locks = metaprot.locks + locks = prototype.get('prototype_locks', '') if locks: text.append("Current lock is |w'{lockstring}'|n".format(lockstring=locks)) else: @@ -1367,14 +1362,14 @@ class OLCMenu(EvMenu): return "{}{}{}".format(olc_options, sep, other_options) -def start_olc(caller, session=None, metaproto=None): +def start_olc(caller, session=None, prototype=None): """ Start menu-driven olc system for prototypes. Args: caller (Object or Account): The entity starting the menu. session (Session, optional): The individual session to get data. - metaproto (MetaProto, optional): Given when editing an existing + prototype (dict, optional): Given when editing an existing prototype rather than creating a new one. """ @@ -1396,7 +1391,7 @@ def start_olc(caller, session=None, metaproto=None): "node_prototype_tags": node_prototype_tags, "node_prototype_locks": node_prototype_locks, } - OLCMenu(caller, menudata, startnode='node_index', session=session, olc_prototype=metaproto) + OLCMenu(caller, menudata, startnode='node_index', session=session, olc_prototype=prototype) # Testing From 405516d5304a1dc2d814f5101c55211c4850e2b5 Mon Sep 17 00:00:00 2001 From: Griatch Date: Thu, 19 Apr 2018 22:47:13 +0200 Subject: [PATCH 101/208] Cherry-pick EvMenu list_node decorator from olc branch --- evennia/utils/evmenu.py | 241 +++++++++++++++++++++++++++++++++++----- 1 file changed, 211 insertions(+), 30 deletions(-) diff --git a/evennia/utils/evmenu.py b/evennia/utils/evmenu.py index 3d8fb6b789..a2a92f5e34 100644 --- a/evennia/utils/evmenu.py +++ b/evennia/utils/evmenu.py @@ -43,13 +43,18 @@ command definition too) with function definitions: def node_with_other_name(caller, input_string): # code return text, options + + def another_node(caller, input_string, **kwargs): + # code + return text, options ``` Where caller is the object using the menu and input_string is the command entered by the user on the *previous* node (the command entered to get to this node). The node function code will only be executed once per node-visit and the system will accept nodes with -both one or two arguments interchangeably. +both one or two arguments interchangeably. It also accepts nodes +that takes **kwargs. The menu tree itself is available on the caller as `caller.ndb._menutree`. This makes it a convenient place to store @@ -82,12 +87,14 @@ menu is immediately exited and the default "look" command is called. the callable. Those kwargs will also be passed into the next node if possible. Such a callable should return either a str or a (str, dict), where the string is the name of the next node to go to and the dict is the new, - (possibly modified) kwarg to pass into the next node. + (possibly modified) kwarg to pass into the next node. If the callable returns + None or the empty string, the current node will be revisited. - `exec` (str, callable or tuple, optional): This takes the same input as `goto` above and runs before it. If given a node name, the node will be executed but will not be considered the next node. If node/callback returns str or (str, dict), these will replace the `goto` step (`goto` callbacks will not fire), with the string being the next node name and the optional dict acting as the kwargs-input for the next node. + If an exec callable returns the empty string (only), the current node is re-run. If key is not given, the option will automatically be identified by its number 1..N. @@ -167,7 +174,7 @@ from evennia import Command, CmdSet from evennia.utils import logger from evennia.utils.evtable import EvTable from evennia.utils.ansi import strip_ansi -from evennia.utils.utils import mod_import, make_iter, pad, m_len +from evennia.utils.utils import mod_import, make_iter, pad, m_len, is_iter from evennia.commands import cmdhandler # read from protocol NAWS later? @@ -182,7 +189,8 @@ _CMD_NOINPUT = cmdhandler.CMD_NOINPUT # i18n from django.utils.translation import ugettext as _ -_ERR_NOT_IMPLEMENTED = _("Menu node '{nodename}' is not implemented. Make another choice.") +_ERR_NOT_IMPLEMENTED = _("Menu node '{nodename}' is either not implemented or " + "caused an error. Make another choice.") _ERR_GENERAL = _("Error in menu node '{nodename}'.") _ERR_NO_OPTION_DESC = _("No description.") _HELP_FULL = _("Commands: , help, quit") @@ -573,6 +581,7 @@ class EvMenu(object): except EvMenuError: errmsg = _ERR_GENERAL.format(nodename=callback) self.caller.msg(errmsg, self._session) + logger.log_trace() raise return ret @@ -606,9 +615,11 @@ class EvMenu(object): nodetext, options = ret, None except KeyError: self.caller.msg(_ERR_NOT_IMPLEMENTED.format(nodename=nodename), session=self._session) + logger.log_trace() raise EvMenuError except Exception: self.caller.msg(_ERR_GENERAL.format(nodename=nodename), session=self._session) + logger.log_trace() raise # store options to make them easier to test @@ -665,9 +676,49 @@ class EvMenu(object): if isinstance(ret, basestring): # only return a value if a string (a goto target), ignore all other returns + if not ret: + # an empty string - rerun the same node + return self.nodename return ret, kwargs return None + def extract_goto_exec(self, nodename, option_dict): + """ + Helper: Get callables and their eventual kwargs. + + Args: + nodename (str): The current node name (used for error reporting). + option_dict (dict): The seleted option's dict. + + Returns: + goto (str, callable or None): The goto directive in the option. + goto_kwargs (dict): Kwargs for `goto` if the former is callable, otherwise empty. + execute (callable or None): Executable given by the `exec` directive. + exec_kwargs (dict): Kwargs for `execute` if it's callable, otherwise empty. + + """ + goto_kwargs, exec_kwargs = {}, {} + goto, execute = option_dict.get("goto", None), option_dict.get("exec", None) + if goto and isinstance(goto, (tuple, list)): + if len(goto) > 1: + goto, goto_kwargs = goto[:2] # ignore any extra arguments + if not hasattr(goto_kwargs, "__getitem__"): + # not a dict-like structure + raise EvMenuError("EvMenu node {}: goto kwargs is not a dict: {}".format( + nodename, goto_kwargs)) + else: + goto = goto[0] + if execute and isinstance(execute, (tuple, list)): + if len(execute) > 1: + execute, exec_kwargs = execute[:2] # ignore any extra arguments + if not hasattr(exec_kwargs, "__getitem__"): + # not a dict-like structure + raise EvMenuError("EvMenu node {}: exec kwargs is not a dict: {}".format( + nodename, goto_kwargs)) + else: + execute = execute[0] + return goto, goto_kwargs, execute, exec_kwargs + def goto(self, nodename, raw_string, **kwargs): """ Run a node by name, optionally dynamically generating that name first. @@ -681,29 +732,6 @@ class EvMenu(object): argument) """ - def _extract_goto_exec(option_dict): - "Helper: Get callables and their eventual kwargs" - goto_kwargs, exec_kwargs = {}, {} - goto, execute = option_dict.get("goto", None), option_dict.get("exec", None) - if goto and isinstance(goto, (tuple, list)): - if len(goto) > 1: - goto, goto_kwargs = goto[:2] # ignore any extra arguments - if not hasattr(goto_kwargs, "__getitem__"): - # not a dict-like structure - raise EvMenuError("EvMenu node {}: goto kwargs is not a dict: {}".format( - nodename, goto_kwargs)) - else: - goto = goto[0] - if execute and isinstance(execute, (tuple, list)): - if len(execute) > 1: - execute, exec_kwargs = execute[:2] # ignore any extra arguments - if not hasattr(exec_kwargs, "__getitem__"): - # not a dict-like structure - raise EvMenuError("EvMenu node {}: exec kwargs is not a dict: {}".format( - nodename, goto_kwargs)) - else: - execute = execute[0] - return goto, goto_kwargs, execute, exec_kwargs if callable(nodename): # run the "goto" callable, if possible @@ -714,6 +742,9 @@ class EvMenu(object): raise EvMenuError( "{}: goto callable must return str or (str, dict)".format(inp_nodename)) nodename, kwargs = nodename[:2] + if not nodename: + # no nodename return. Re-run current node + nodename = self.nodename try: # execute the found node, make use of the returns. nodetext, options = self._execute_node(nodename, raw_string, **kwargs) @@ -746,12 +777,12 @@ class EvMenu(object): desc = dic.get("desc", dic.get("text", None)) if "_default" in keys: keys = [key for key in keys if key != "_default"] - goto, goto_kwargs, execute, exec_kwargs = _extract_goto_exec(dic) + goto, goto_kwargs, execute, exec_kwargs = self.extract_goto_exec(nodename, dic) self.default = (goto, goto_kwargs, execute, exec_kwargs) else: # use the key (only) if set, otherwise use the running number keys = list(make_iter(dic.get("key", str(inum + 1).strip()))) - goto, goto_kwargs, execute, exec_kwargs = _extract_goto_exec(dic) + goto, goto_kwargs, execute, exec_kwargs = self.extract_goto_exec(nodename, dic) if keys: display_options.append((keys[0], desc)) for key in keys: @@ -945,14 +976,164 @@ class EvMenu(object): node (str): The formatted node to display. """ + screen_width = self._session.protocol_flags.get("SCREENWIDTH", {0: 78})[0] + nodetext_width_max = max(m_len(line) for line in nodetext.split("\n")) options_width_max = max(m_len(line) for line in optionstext.split("\n")) - total_width = max(options_width_max, nodetext_width_max) + total_width = min(screen_width, max(options_width_max, nodetext_width_max)) separator1 = "_" * total_width + "\n\n" if nodetext_width_max else "" separator2 = "\n" + "_" * total_width + "\n\n" if total_width else "" return separator1 + "|n" + nodetext + "|n" + separator2 + "|n" + optionstext +# ----------------------------------------------------------- +# +# List node (decorator turning a node into a list with +# look/edit/add functionality for the elements) +# +# ----------------------------------------------------------- + +def list_node(option_generator, select=None, pagesize=10): + """ + Decorator for making an EvMenu node into a multi-page list node. Will add new options, + prepending those options added in the node. + + Args: + option_generator (callable or list): A list of strings indicating the options, or a callable + that is called as option_generator(caller) to produce such a list. + select (callable, option): Will be called as select(caller, menuchoice) + where menuchoice is the chosen option as a string. Should return the target node to + goto after this selection (or None to repeat the list-node). Note that if this is not + given, the decorated node must itself provide a way to continue from the node! + pagesize (int): How many options to show per page. + + Example: + @list_node(['foo', 'bar'], select) + def node_index(caller): + text = "describing the list" + return text, [] + + Notes: + All normal `goto` or `exec` callables returned from the decorated nodes will, if they accept + **kwargs, get a new kwarg 'available_choices' injected. These are the ordered list of named + options (descs) visible on the current node page. + + """ + + def decorator(func): + + def _select_parser(caller, raw_string, **kwargs): + """ + Parse the select action + """ + available_choices = kwargs.get("available_choices", []) + + try: + index = int(raw_string.strip()) - 1 + selection = available_choices[index] + except Exception: + caller.msg("|rInvalid choice.|n") + else: + if select: + try: + return select(caller, selection) + except Exception: + logger.log_trace() + return None + + def _list_node(caller, raw_string, **kwargs): + + option_list = option_generator(caller) \ + if callable(option_generator) else option_generator + + npages = 0 + page_index = 0 + page = [] + options = [] + + if option_list: + nall_options = len(option_list) + pages = [option_list[ind:ind + pagesize] + for ind in range(0, nall_options, pagesize)] + npages = len(pages) + + page_index = max(0, min(npages - 1, kwargs.get("optionpage_index", 0))) + page = pages[page_index] + + text = "" + extra_text = None + + # dynamic, multi-page option list. Each selection leads to the `select` + # callback being called with a result from the available choices + options.extend([{"desc": opt, + "goto": (_select_parser, + {"available_choices": page})} for opt in page]) + + if npages > 1: + # if the goto callable returns None, the same node is rerun, and + # kwargs not used by the callable are passed on to the node. This + # allows us to call ourselves over and over, using different kwargs. + options.append({"key": ("|Wcurrent|n", "c"), + "desc": "|W({}/{})|n".format(page_index + 1, npages), + "goto": (lambda caller: None, + {"optionpage_index": page_index})}) + if page_index > 0: + options.append({"key": ("|wp|Wrevious page|n", "p"), + "goto": (lambda caller: None, + {"optionpage_index": page_index - 1})}) + if page_index < npages - 1: + options.append({"key": ("|wn|Wext page|n", "n"), + "goto": (lambda caller: None, + {"optionpage_index": page_index + 1})}) + + # add data from the decorated node + + decorated_options = [] + try: + text, decorated_options = func(caller, raw_string) + except TypeError: + try: + text, decorated_options = func(caller) + except Exception: + raise + except Exception: + logger.log_trace() + else: + if isinstance(decorated_options, {}): + decorated_options = [decorated_options] + else: + decorated_options = make_iter(decorated_options) + + extra_options = [] + for eopt in decorated_options: + cback = ("goto" in eopt and "goto") or ("exec" in eopt and "exec") or None + if cback: + signature = eopt[cback] + if callable(signature): + # callable with no kwargs defined + eopt[cback] = (signature, {"available_choices": page}) + elif is_iter(signature): + if len(signature) > 1 and isinstance(signature[1], dict): + signature[1]["available_choices"] = page + eopt[cback] = signature + elif signature: + # a callable alone in a tuple (i.e. no previous kwargs) + eopt[cback] = (signature[0], {"available_choices": page}) + else: + # malformed input. + logger.log_err("EvMenu @list_node decorator found " + "malformed option to decorate: {}".format(eopt)) + extra_options.append(eopt) + + options.extend(extra_options) + text = text + "\n\n" + extra_text if extra_text else text + + return text, options + + return _list_node + return decorator + + # ------------------------------------------------------------------------------------------------- # # Simple input shortcuts From 894969da13480e5720788ca01337a521fe51e2f1 Mon Sep 17 00:00:00 2001 From: Griatch Date: Fri, 20 Apr 2018 19:51:12 +0200 Subject: [PATCH 102/208] Fix unittests --- evennia/contrib/tests.py | 18 +++++++++--------- evennia/utils/evmenu.py | 6 +++++- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/evennia/contrib/tests.py b/evennia/contrib/tests.py index 30bf71dcc8..be5921ff67 100644 --- a/evennia/contrib/tests.py +++ b/evennia/contrib/tests.py @@ -670,7 +670,7 @@ class TestGenderSub(CommandTest): char = create_object(gendersub.GenderCharacter, key="Gendered", location=self.room1) txt = "Test |p gender" self.assertEqual(gendersub._RE_GENDER_PRONOUN.sub(char._get_pronoun, txt), "Test their gender") - + # test health bar contrib from evennia.contrib import health_bar @@ -798,7 +798,7 @@ from evennia.contrib import talking_npc class TestTalkingNPC(CommandTest): def test_talkingnpc(self): npc = create_object(talking_npc.TalkingNPC, key="npctalker", location=self.room1) - self.call(talking_npc.CmdTalk(), "", "(You walk up and talk to Char.)|") + self.call(talking_npc.CmdTalk(), "", "(You walk up and talk to Char.)") npc.delete() @@ -966,7 +966,7 @@ class TestTurnBattleCmd(CommandTest): self.call(tb_basic.CmdPass(), "", "You can only do that in combat. (see: help fight)") self.call(tb_basic.CmdDisengage(), "", "You can only do that in combat. (see: help fight)") self.call(tb_basic.CmdRest(), "", "Char rests to recover HP.") - + # Test equipment commands def test_turnbattleequipcmd(self): # Start with equip module specific commands. @@ -984,7 +984,7 @@ class TestTurnBattleCmd(CommandTest): self.call(tb_equip.CmdPass(), "", "You can only do that in combat. (see: help fight)") self.call(tb_equip.CmdDisengage(), "", "You can only do that in combat. (see: help fight)") self.call(tb_equip.CmdRest(), "", "Char rests to recover HP.") - + # Test range commands def test_turnbattlerangecmd(self): # Start with range module specific commands. @@ -998,7 +998,7 @@ class TestTurnBattleCmd(CommandTest): self.call(tb_range.CmdPass(), "", "You can only do that in combat. (see: help fight)") self.call(tb_range.CmdDisengage(), "", "You can only do that in combat. (see: help fight)") self.call(tb_range.CmdRest(), "", "Char rests to recover HP.") - + class TestTurnBattleFunc(EvenniaTest): @@ -1080,7 +1080,7 @@ class TestTurnBattleFunc(EvenniaTest): self.assertTrue(turnhandler.db.fighters == [joiner, attacker, defender]) # Remove the script at the end turnhandler.stop() - + # Test the combat functions in tb_equip too. They work mostly the same. def test_tbequipfunc(self): attacker = create_object(tb_equip.TBEquipCharacter, key="Attacker") @@ -1159,7 +1159,7 @@ class TestTurnBattleFunc(EvenniaTest): self.assertTrue(turnhandler.db.fighters == [joiner, attacker, defender]) # Remove the script at the end turnhandler.stop() - + # Test combat functions in tb_range too. def test_tbrangefunc(self): testroom = create_object(DefaultRoom, key="Test Room") @@ -1264,7 +1264,7 @@ Bar -Qux""" class TestTreeSelectFunc(EvenniaTest): - + def test_tree_functions(self): # Dash counter self.assertTrue(tree_select.dashcount("--test") == 2) @@ -1279,7 +1279,7 @@ class TestTreeSelectFunc(EvenniaTest): # Option list to menu options test_optlist = tree_select.parse_opts(TREE_MENU_TESTSTR, category_index=2) optlist_to_menu_expected_result = [{'goto': ['menunode_treeselect', {'newindex': 3}], 'key': 'Baz 1'}, - {'goto': ['menunode_treeselect', {'newindex': 4}], 'key': 'Baz 2'}, + {'goto': ['menunode_treeselect', {'newindex': 4}], 'key': 'Baz 2'}, {'goto': ['menunode_treeselect', {'newindex': 1}], 'key': ['<< Go Back', 'go back', 'back'], 'desc': 'Return to the previous menu.'}] self.assertTrue(tree_select.optlist_to_menuoptions(TREE_MENU_TESTSTR, test_optlist, 2, True, True) == optlist_to_menu_expected_result) diff --git a/evennia/utils/evmenu.py b/evennia/utils/evmenu.py index a2a92f5e34..0e494ca3e0 100644 --- a/evennia/utils/evmenu.py +++ b/evennia/utils/evmenu.py @@ -976,7 +976,11 @@ class EvMenu(object): node (str): The formatted node to display. """ - screen_width = self._session.protocol_flags.get("SCREENWIDTH", {0: 78})[0] + if self._session: + screen_width = self._session.protocol_flags.get( + "SCREENWIDTH", {0: _MAX_TEXT_WIDTH})[0] + else: + screen_width = _MAX_TEXT_WIDTH nodetext_width_max = max(m_len(line) for line in nodetext.split("\n")) options_width_max = max(m_len(line) for line in optionstext.split("\n")) From a71f112d8009b9b99e9fd55a2fab2aadb978dcb5 Mon Sep 17 00:00:00 2001 From: Griatch Date: Sat, 21 Apr 2018 15:31:30 +0200 Subject: [PATCH 103/208] Inject selection in list_node decorator if select kwarg is a string --- evennia/utils/evmenu.py | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/evennia/utils/evmenu.py b/evennia/utils/evmenu.py index 0e494ca3e0..f6806c06b8 100644 --- a/evennia/utils/evmenu.py +++ b/evennia/utils/evmenu.py @@ -1005,10 +1005,12 @@ def list_node(option_generator, select=None, pagesize=10): Args: option_generator (callable or list): A list of strings indicating the options, or a callable that is called as option_generator(caller) to produce such a list. - select (callable, option): Will be called as select(caller, menuchoice) - where menuchoice is the chosen option as a string. Should return the target node to - goto after this selection (or None to repeat the list-node). Note that if this is not - given, the decorated node must itself provide a way to continue from the node! + select (callable or str, optional): Node to redirect a selection to. Its `**kwargs` will + contain the `available_choices` list and `selection` will hold one of the elements in + that list. If a callable, it will be called as select(caller, menuchoice) where + menuchoice is the chosen option as a string. Should return the target node to goto after + this selection (or None to repeat the list-node). Note that if this is not given, the + decorated node must itself provide a way to continue from the node! pagesize (int): How many options to show per page. Example: @@ -1038,11 +1040,16 @@ def list_node(option_generator, select=None, pagesize=10): except Exception: caller.msg("|rInvalid choice.|n") else: - if select: + if callable(select): try: return select(caller, selection) except Exception: logger.log_trace() + else: + # we assume a string was given, we inject the result into the kwargs + # to pass on to the next node + kwargs['selection'] = selection + return str(select) return None def _list_node(caller, raw_string, **kwargs): From b580123b19564c3b8c5da42513c95b6aed35a1a6 Mon Sep 17 00:00:00 2001 From: friarzen Date: Sat, 21 Apr 2018 17:03:01 +0000 Subject: [PATCH 104/208] Attempt to make append/replace dialog text more clear --- .../webclient/static/webclient/js/webclient_gui.js | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/evennia/web/webclient/static/webclient/js/webclient_gui.js b/evennia/web/webclient/static/webclient/js/webclient_gui.js index 8929a7529c..e1ed4d31fd 100644 --- a/evennia/web/webclient/static/webclient/js/webclient_gui.js +++ b/evennia/web/webclient/static/webclient/js/webclient_gui.js @@ -484,13 +484,13 @@ function onSplitDialog() { dialog.append(''); dialog.append(''); - dialog.append("

New First Pane Flow

"); - dialog.append('append
'); - dialog.append('replace
'); + dialog.append("

New First Pane

"); + dialog.append('append new incoming messages
'); + dialog.append('replace old messages with new ones
'); - dialog.append("

New Second Pane Flow

"); - dialog.append('append
'); - dialog.append('replace
'); + dialog.append("

New Second Pane

"); + dialog.append('append new incoming messages
'); + dialog.append('replace old messages with new ones
'); dialog.append('
Split It
'); From 3bebe2ac22674f1f7773f3e034b3bdc9f3eff14a Mon Sep 17 00:00:00 2001 From: Tehom Date: Sun, 22 Apr 2018 06:56:27 -0400 Subject: [PATCH 105/208] Fix regex in CommandTest breaking negative numbers --- evennia/commands/default/tests.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/evennia/commands/default/tests.py b/evennia/commands/default/tests.py index e3715a30c8..6d122b11d6 100644 --- a/evennia/commands/default/tests.py +++ b/evennia/commands/default/tests.py @@ -31,7 +31,7 @@ from evennia import DefaultObject, DefaultCharacter # set up signal here since we are not starting the server -_RE = re.compile(r"^\+|-+\+|\+-+|--*|\|(?:\s|$)", re.MULTILINE) +_RE = re.compile(r"^\+|-+\+|\+-+|--+|\|(?:\s|$)", re.MULTILINE) # ------------------------------------------------------------ From 1480ac9acc0419980f2c57b6b740e5bf9ae3b96a Mon Sep 17 00:00:00 2001 From: Tehom Date: Sun, 22 Apr 2018 07:25:01 -0400 Subject: [PATCH 106/208] Add hyphens to some tests. Probably more to come --- evennia/commands/default/tests.py | 14 +++++++------- evennia/contrib/tests.py | 10 +++++----- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/evennia/commands/default/tests.py b/evennia/commands/default/tests.py index 6d122b11d6..be889317d5 100644 --- a/evennia/commands/default/tests.py +++ b/evennia/commands/default/tests.py @@ -128,7 +128,7 @@ class TestGeneral(CommandTest): self.call(general.CmdPose(), "looks around", "Char looks around") def test_nick(self): - self.call(general.CmdNick(), "testalias = testaliasedstring1", "Inputlinenick 'testalias' mapped to 'testaliasedstring1'.") + self.call(general.CmdNick(), "testalias = testaliasedstring1", "Inputline-nick 'testalias' mapped to 'testaliasedstring1'.") self.call(general.CmdNick(), "/account testalias = testaliasedstring2", "Accountnick 'testalias' mapped to 'testaliasedstring2'.") self.call(general.CmdNick(), "/object testalias = testaliasedstring3", "Objectnick 'testalias' mapped to 'testaliasedstring3'.") self.assertEqual(u"testaliasedstring1", self.char1.nicks.get("testalias")) @@ -190,14 +190,14 @@ class TestAdmin(CommandTest): self.call(admin.CmdWall(), "Test", "Announcing to all connected sessions ...") def test_ban(self): - self.call(admin.CmdBan(), "Char", "NameBan char was added.") + self.call(admin.CmdBan(), "Char", "Name-Ban char was added.") class TestAccount(CommandTest): def test_ooc_look(self): if settings.MULTISESSION_MODE < 2: - self.call(account.CmdOOCLook(), "", "You are outofcharacter (OOC).", caller=self.account) + self.call(account.CmdOOCLook(), "", "You are out-of-character (OOC).", caller=self.account) if settings.MULTISESSION_MODE == 2: self.call(account.CmdOOCLook(), "", "Account TestAccount (you are OutofCharacter)", caller=self.account) @@ -250,8 +250,8 @@ class TestBuilding(CommandTest): def test_attribute_commands(self): self.call(building.CmdSetAttribute(), "Obj/test1=\"value1\"", "Created attribute Obj/test1 = 'value1'") self.call(building.CmdSetAttribute(), "Obj2/test2=\"value2\"", "Created attribute Obj2/test2 = 'value2'") - self.call(building.CmdMvAttr(), "Obj2/test2 = Obj/test3", "Moved Obj2.test2 > Obj.test3") - self.call(building.CmdCpAttr(), "Obj/test1 = Obj2/test3", "Copied Obj.test1 > Obj2.test3") + self.call(building.CmdMvAttr(), "Obj2/test2 = Obj/test3", "Moved Obj2.test2 -> Obj.test3") + self.call(building.CmdCpAttr(), "Obj/test1 = Obj2/test3", "Copied Obj.test1 -> Obj2.test3") self.call(building.CmdWipe(), "Obj2/test2/test3", "Wiped attributes test2,test3 on Obj2.") def test_name(self): @@ -277,7 +277,7 @@ class TestBuilding(CommandTest): def test_exit_commands(self): self.call(building.CmdOpen(), "TestExit1=Room2", "Created new Exit 'TestExit1' from Room to Room2") - self.call(building.CmdLink(), "TestExit1=Room", "Link created TestExit1 > Room (one way).") + self.call(building.CmdLink(), "TestExit1=Room", "Link created TestExit1 -> Room (one way).") self.call(building.CmdUnLink(), "TestExit1", "Former exit TestExit1 no longer links anywhere.") def test_set_home(self): @@ -412,7 +412,7 @@ class TestComms(CommandTest): class TestBatchProcess(CommandTest): def test_batch_commands(self): # cannot test batchcode here, it must run inside the server process - self.call(batchprocess.CmdBatchCommands(), "example_batch_cmds", "Running Batchcommand processor Automatic mode for example_batch_cmds") + self.call(batchprocess.CmdBatchCommands(), "example_batch_cmds", "Running Batch-command processor - Automatic mode for example_batch_cmds") # we make sure to delete the button again here to stop the running reactor confirm = building.CmdDestroy.confirm building.CmdDestroy.confirm = False diff --git a/evennia/contrib/tests.py b/evennia/contrib/tests.py index ff6b01d5cf..3ba56d3410 100644 --- a/evennia/contrib/tests.py +++ b/evennia/contrib/tests.py @@ -688,7 +688,7 @@ class TestMail(CommandTest): "You have received a new @mail from TestAccount2(account 2)|You sent your message.", caller=self.account2) self.call(mail.CmdMail(), "TestAccount=Message 1", "You sent your message.", caller=self.account2) self.call(mail.CmdMail(), "TestAccount=Message 2", "You sent your message.", caller=self.account2) - self.call(mail.CmdMail(), "", "| ID: From: Subject:", caller=self.account) + self.call(mail.CmdMail(), "", "------------------------------------------------------------------------------| ID: From: Subject:", caller=self.account) self.call(mail.CmdMail(), "2", "From: TestAccount2", caller=self.account) self.call(mail.CmdMail(), "/forward TestAccount2 = 1/Forward message", "You sent your message.|Message forwarded.", caller=self.account) self.call(mail.CmdMail(), "/reply 2=Reply Message2", "You sent your message.", caller=self.account) @@ -714,9 +714,9 @@ class TestMapBuilder(CommandTest): "evennia.contrib.mapbuilder.EXAMPLE2_MAP evennia.contrib.mapbuilder.EXAMPLE2_LEGEND", """Creating Map...|≈ ≈ ≈ ≈ ≈ -≈ ♣♣♣ ≈ +≈ ♣-♣-♣ ≈ ≈ ♣ ♣ ♣ ≈ - ≈ ♣♣♣ ≈ + ≈ ♣-♣-♣ ≈ ≈ ≈ ≈ ≈ ≈ |Creating Landmass...|""") @@ -759,8 +759,8 @@ from evennia.contrib import simpledoor class TestSimpleDoor(CommandTest): def test_cmdopen(self): self.call(simpledoor.CmdOpen(), "newdoor;door:contrib.simpledoor.SimpleDoor,backdoor;door = Room2", - "Created new Exit 'newdoor' from Room to Room2 (aliases: door).|Note: A doortype exit was " - "created ignored eventual custom returnexit type.|Created new Exit 'newdoor' from Room2 to Room (aliases: door).") + "Created new Exit 'newdoor' from Room to Room2 (aliases: door).|Note: A door-type exit was " + "created - ignored eventual custom return-exit type.|Created new Exit 'newdoor' from Room2 to Room (aliases: door).") self.call(simpledoor.CmdOpenCloseDoor(), "newdoor", "You close newdoor.", cmdstring="close") self.call(simpledoor.CmdOpenCloseDoor(), "newdoor", "newdoor is already closed.", cmdstring="close") self.call(simpledoor.CmdOpenCloseDoor(), "newdoor", "You open newdoor.", cmdstring="open") From b34920059b8e8012ac1dc5ae95ab217b5882c2cf Mon Sep 17 00:00:00 2001 From: Tehom Date: Sun, 22 Apr 2018 07:37:14 -0400 Subject: [PATCH 107/208] Add yet more hyphens. --- evennia/commands/default/tests.py | 4 ++-- evennia/contrib/tests.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/evennia/commands/default/tests.py b/evennia/commands/default/tests.py index be889317d5..a0492d6aab 100644 --- a/evennia/commands/default/tests.py +++ b/evennia/commands/default/tests.py @@ -129,8 +129,8 @@ class TestGeneral(CommandTest): def test_nick(self): self.call(general.CmdNick(), "testalias = testaliasedstring1", "Inputline-nick 'testalias' mapped to 'testaliasedstring1'.") - self.call(general.CmdNick(), "/account testalias = testaliasedstring2", "Accountnick 'testalias' mapped to 'testaliasedstring2'.") - self.call(general.CmdNick(), "/object testalias = testaliasedstring3", "Objectnick 'testalias' mapped to 'testaliasedstring3'.") + self.call(general.CmdNick(), "/account testalias = testaliasedstring2", "Account-nick 'testalias' mapped to 'testaliasedstring2'.") + self.call(general.CmdNick(), "/object testalias = testaliasedstring3", "Object-nick 'testalias' mapped to 'testaliasedstring3'.") self.assertEqual(u"testaliasedstring1", self.char1.nicks.get("testalias")) self.assertEqual(None, self.char1.nicks.get("testalias", category="account")) self.assertEqual(u"testaliasedstring2", self.char1.account.nicks.get("testalias", category="account")) diff --git a/evennia/contrib/tests.py b/evennia/contrib/tests.py index 3ba56d3410..78ecc0f4fb 100644 --- a/evennia/contrib/tests.py +++ b/evennia/contrib/tests.py @@ -689,7 +689,7 @@ class TestMail(CommandTest): self.call(mail.CmdMail(), "TestAccount=Message 1", "You sent your message.", caller=self.account2) self.call(mail.CmdMail(), "TestAccount=Message 2", "You sent your message.", caller=self.account2) self.call(mail.CmdMail(), "", "------------------------------------------------------------------------------| ID: From: Subject:", caller=self.account) - self.call(mail.CmdMail(), "2", "From: TestAccount2", caller=self.account) + self.call(mail.CmdMail(), "2", "------------------------------------------------------------------------------\nFrom: TestAccount2", caller=self.account) self.call(mail.CmdMail(), "/forward TestAccount2 = 1/Forward message", "You sent your message.|Message forwarded.", caller=self.account) self.call(mail.CmdMail(), "/reply 2=Reply Message2", "You sent your message.", caller=self.account) self.call(mail.CmdMail(), "/delete 2", "Message 2 deleted", caller=self.account) From c97ba87e9bcd32c36fae3948ee8505ed85210b6e Mon Sep 17 00:00:00 2001 From: Griatch Date: Sun, 22 Apr 2018 13:39:47 +0200 Subject: [PATCH 108/208] Fix error if sending string to list_node select callback --- evennia/utils/evmenu.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/evennia/utils/evmenu.py b/evennia/utils/evmenu.py index f6806c06b8..0de33de348 100644 --- a/evennia/utils/evmenu.py +++ b/evennia/utils/evmenu.py @@ -1045,11 +1045,12 @@ def list_node(option_generator, select=None, pagesize=10): return select(caller, selection) except Exception: logger.log_trace() - else: + elif select: # we assume a string was given, we inject the result into the kwargs # to pass on to the next node kwargs['selection'] = selection return str(select) + # this means the previous node will be re-run with these same kwargs return None def _list_node(caller, raw_string, **kwargs): From 24fc553784492c2164d059a9dc1d0cfdb1ee20e5 Mon Sep 17 00:00:00 2001 From: Griatch Date: Sun, 22 Apr 2018 14:48:55 +0200 Subject: [PATCH 109/208] Make EvTable respect col-widths while retaining total table width. Resolves #1614. --- evennia/contrib/mail.py | 2 +- evennia/contrib/tests.py | 2 +- evennia/utils/evtable.py | 96 ++++++++++++++++++++++++++++++++-------- 3 files changed, 79 insertions(+), 21 deletions(-) diff --git a/evennia/contrib/mail.py b/evennia/contrib/mail.py index 6e8585136d..dbecb62fcd 100644 --- a/evennia/contrib/mail.py +++ b/evennia/contrib/mail.py @@ -253,7 +253,7 @@ class CmdMail(default_cmds.MuxCommand): index += 1 table.reformat_column(0, width=6) - table.reformat_column(1, width=17) + table.reformat_column(1, width=18) table.reformat_column(2, width=34) table.reformat_column(3, width=13) table.reformat_column(4, width=7) diff --git a/evennia/contrib/tests.py b/evennia/contrib/tests.py index ff6b01d5cf..1e526bd9cb 100644 --- a/evennia/contrib/tests.py +++ b/evennia/contrib/tests.py @@ -688,7 +688,7 @@ class TestMail(CommandTest): "You have received a new @mail from TestAccount2(account 2)|You sent your message.", caller=self.account2) self.call(mail.CmdMail(), "TestAccount=Message 1", "You sent your message.", caller=self.account2) self.call(mail.CmdMail(), "TestAccount=Message 2", "You sent your message.", caller=self.account2) - self.call(mail.CmdMail(), "", "| ID: From: Subject:", caller=self.account) + self.call(mail.CmdMail(), "", "| ID: From: Subject:", caller=self.account) self.call(mail.CmdMail(), "2", "From: TestAccount2", caller=self.account) self.call(mail.CmdMail(), "/forward TestAccount2 = 1/Forward message", "You sent your message.|Message forwarded.", caller=self.account) self.call(mail.CmdMail(), "/reply 2=Reply Message2", "You sent your message.", caller=self.account) diff --git a/evennia/utils/evtable.py b/evennia/utils/evtable.py index 31218189ab..ffb29873c4 100644 --- a/evennia/utils/evtable.py +++ b/evennia/utils/evtable.py @@ -893,6 +893,9 @@ class EvColumn(object): """ col = self.column + # fixed options for the column will override those requested in the call! + # this is particularly relevant to things like width/height, to avoid + # fixed-widths columns from being auto-balanced kwargs.update(self.options) # use fixed width or adjust to the largest cell if "width" not in kwargs: @@ -1283,25 +1286,59 @@ class EvTable(object): cwidths_min = [max(cell.get_min_width() for cell in col) for col in self.worktable] cwmin = sum(cwidths_min) - if cwmin > width: - # we cannot shrink any more - raise Exception("Cannot shrink table width to %s. Minimum size is %s." % (self.width, cwmin)) + # get which cols have separately set widths - these should be locked + # note that we need to remove cwidths_min for each lock to avoid counting + # it twice (in cwmin and in locked_cols) + locked_cols = {icol: col.options['width'] - cwidths_min[icol] + for icol, col in enumerate(self.worktable) if 'width' in col.options} + locked_width = sum(locked_cols.values()) + + excess = width - cwmin - locked_width + + if len(locked_cols) >= ncols and excess: + # we can't adjust the width at all - all columns are locked + raise Exception("Cannot balance table to width %s - " + "all columns have a set, fixed width summing to %s!" % ( + self.width, sum(cwidths))) + + if excess < 0: + # the locked cols makes it impossible + raise Exception("Cannot shrink table width to %s. " + "Minimum size (and/or fixed-width columns) " + "sets minimum at %s." % (self.width, cwmin + locked_width)) - excess = width - cwmin if self.evenwidth: # make each column of equal width - for _ in range(excess): + # use cwidths as a work-array to track weights + cwidths = copy(cwidths_min) + correction = 0 + while correction < excess: # flood-fill the minimum table starting with the smallest columns - ci = cwidths_min.index(min(cwidths_min)) - cwidths_min[ci] += 1 + ci = cwidths.index(min(cwidths)) + if ci in locked_cols: + # locked column, make sure it's not picked again + cwidths[ci] += 9999 + cwidths_min[ci] = locked_cols[ci] + else: + cwidths_min[ci] += 1 + correction += 1 cwidths = cwidths_min else: # make each column expand more proportional to their data size - for _ in range(excess): + # we use cwidth as a work-array to track weights + correction = 0 + while correction < excess: # fill wider columns first ci = cwidths.index(max(cwidths)) - cwidths_min[ci] += 1 - cwidths[ci] -= 3 + if ci in locked_cols: + # locked column, make sure it's not picked again + cwidths[ci] -= 9999 + cwidths_min[ci] = locked_cols[ci] + else: + cwidths_min[ci] += 1 + correction += 1 + # give a just changed col less prio next run + cwidths[ci] -= 3 cwidths = cwidths_min # reformat worktable (for width align) @@ -1323,28 +1360,46 @@ class EvTable(object): for cell in (col[iy] for col in self.worktable)) for iy in range(nrowmax)] chmin = sum(cheights_min) + # get which cols have separately set heights - these should be locked + # note that we need to remove cheights_min for each lock to avoid counting + # it twice (in chmin and in locked_cols) + locked_cols = {icol: col.options['height'] - cheights_min[icol] + for icol, col in enumerate(self.worktable) if 'height' in col.options} + locked_height = sum(locked_cols.values()) + + excess = self.height - chmin - locked_height + if chmin > self.height: # we cannot shrink any more - raise Exception("Cannot shrink table height to %s. Minimum size is %s." % (self.height, chmin)) + raise Exception("Cannot shrink table height to %s. Minimum " + "size (and/or fixed-height rows) sets minimum at %s." % ( + self.height, chmin + locked_height)) # now we add all the extra height up to the desired table-height. # We do this so that the tallest cells gets expanded first (and # thus avoid getting cropped) - excess = self.height - chmin even = self.height % 2 == 0 - for position in range(excess): + correction = 0 + while correction < excess: # expand the cells with the most rows first - if 0 <= position < nrowmax and nrowmax > 1: + if 0 <= correction < nrowmax and nrowmax > 1: # avoid adding to header first round (looks bad on very small tables) ci = cheights[1:].index(max(cheights[1:])) + 1 else: ci = cheights.index(max(cheights)) - cheights_min[ci] += 1 - if ci == 0 and self.header: - # it doesn't look very good if header expands too fast - cheights[ci] -= 2 if even else 3 - cheights[ci] -= 2 if even else 1 + if ci in locked_cols: + # locked row, make sure it's not picked again + cheights[ci] -= 9999 + cheights_min[ci] = locked_cols[ci] + else: + cheights_min[ci] += 1 + # change balance + if ci == 0 and self.header: + # it doesn't look very good if header expands too fast + cheights[ci] -= 2 if even else 3 + cheights[ci] -= 2 if even else 1 + correction += 1 cheights = cheights_min # we must tell cells to crop instead of expanding @@ -1554,6 +1609,8 @@ class EvTable(object): """ if index > len(self.table): raise Exception("Not a valid column index") + # we update the columns' options which means eventual width/height + # will be 'locked in' and withstand auto-balancing width/height from the table later self.table[index].options.update(kwargs) self.table[index].reformat(**kwargs) @@ -1569,6 +1626,7 @@ class EvTable(object): def __str__(self): """print table (this also balances it)""" + # h = "12345678901234567890123456789012345678901234567890123456789012345678901234567890" return str(unicode(ANSIString("\n").join([line for line in self._generate_lines()]))) def __unicode__(self): From 895cc2de5214315e2888da399e1cffd4cfb3de4a Mon Sep 17 00:00:00 2001 From: Rachel Blackman Date: Sun, 8 Apr 2018 13:11:35 -0700 Subject: [PATCH 110/208] Add /contains switch to find. --- evennia/commands/default/building.py | 15 ++++++++++----- evennia/commands/default/tests.py | 1 + 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/evennia/commands/default/building.py b/evennia/commands/default/building.py index 702f2e241a..a111e2c878 100644 --- a/evennia/commands/default/building.py +++ b/evennia/commands/default/building.py @@ -2270,11 +2270,12 @@ class CmdFind(COMMAND_DEFAULT_CLASS): @locate - this is a shorthand for using the /loc switch. Switches: - room - only look for rooms (location=None) - exit - only look for exits (destination!=None) - char - only look for characters (BASE_CHARACTER_TYPECLASS) - exact- only exact matches are returned. - loc - display object location if exists and match has one result + room - only look for rooms (location=None) + exit - only look for exits (destination!=None) + char - only look for characters (BASE_CHARACTER_TYPECLASS) + exact - only exact matches are returned. + loc - display object location if exists and match has one result + contains- search for names containing the string, rather than starting with. Searches the database for an object of a particular name or exact #dbref. Use *accountname to search for an account. The switches allows for @@ -2359,6 +2360,10 @@ class CmdFind(COMMAND_DEFAULT_CLASS): keyquery = Q(db_key__iexact=searchstring, id__gte=low, id__lte=high) aliasquery = Q(db_tags__db_key__iexact=searchstring, db_tags__db_tagtype__iexact="alias", id__gte=low, id__lte=high) + elif "contains" in switches: + keyquery = Q(db_key__icontains=searchstring, id__gte=low, id__lte=high) + aliasquery = Q(db_tags__db_key__icontains=searchstring, + db_tags__db_tagtype__iexact="alias", id__gte=low, id__lte=high) else: keyquery = Q(db_key__istartswith=searchstring, id__gte=low, id__lte=high) aliasquery = Q(db_tags__db_key__istartswith=searchstring, diff --git a/evennia/commands/default/tests.py b/evennia/commands/default/tests.py index 8e73a8bf5d..37e4b07b03 100644 --- a/evennia/commands/default/tests.py +++ b/evennia/commands/default/tests.py @@ -339,6 +339,7 @@ class TestBuilding(CommandTest): self.call(building.CmdFind(), "Char2", expect, cmdstring="@locate") self.call(building.CmdFind(), "/l Char2", expect, cmdstring="find") # /l switch is abbreviated form of /loc self.call(building.CmdFind(), "Char2", "One Match", cmdstring="@find") + self.call(building.CmdFind(), "/contains om2", "One Match") def test_script(self): self.call(building.CmdScript(), "Obj = scripts.Script", "Script scripts.Script successfully added") From 3b99e10a6a3fcbed903294b5501899e6b9e8ed12 Mon Sep 17 00:00:00 2001 From: Rachel Blackman Date: Sun, 8 Apr 2018 13:30:01 -0700 Subject: [PATCH 111/208] 0.8 has switches defined in the command, need to make the change from the 0.7 changeset this originated from. --- evennia/commands/default/building.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/evennia/commands/default/building.py b/evennia/commands/default/building.py index a111e2c878..dee9cd737e 100644 --- a/evennia/commands/default/building.py +++ b/evennia/commands/default/building.py @@ -2286,7 +2286,7 @@ class CmdFind(COMMAND_DEFAULT_CLASS): key = "@find" aliases = "@search, @locate" - switch_options = ("room", "exit", "char", "exact", "loc") + switch_options = ("room", "exit", "char", "exact", "loc", "contains") locks = "cmd:perm(find) or perm(Builder)" help_category = "Building" From 84620256c682bbaf6254e1ddc8df22387945183a Mon Sep 17 00:00:00 2001 From: Rachel Blackman Date: Sat, 21 Apr 2018 17:00:38 -0700 Subject: [PATCH 112/208] Switch /contains to default, add /startswith switch instead. --- evennia/commands/default/building.py | 24 ++++++++++++------------ evennia/commands/default/tests.py | 4 ++-- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/evennia/commands/default/building.py b/evennia/commands/default/building.py index dee9cd737e..3cda726881 100644 --- a/evennia/commands/default/building.py +++ b/evennia/commands/default/building.py @@ -2270,12 +2270,12 @@ class CmdFind(COMMAND_DEFAULT_CLASS): @locate - this is a shorthand for using the /loc switch. Switches: - room - only look for rooms (location=None) - exit - only look for exits (destination!=None) - char - only look for characters (BASE_CHARACTER_TYPECLASS) - exact - only exact matches are returned. - loc - display object location if exists and match has one result - contains- search for names containing the string, rather than starting with. + room - only look for rooms (location=None) + exit - only look for exits (destination!=None) + char - only look for characters (BASE_CHARACTER_TYPECLASS) + exact - only exact matches are returned. + loc - display object location if exists and match has one result + startswith - search for names starting with the string, rather than containing Searches the database for an object of a particular name or exact #dbref. Use *accountname to search for an account. The switches allows for @@ -2286,7 +2286,7 @@ class CmdFind(COMMAND_DEFAULT_CLASS): key = "@find" aliases = "@search, @locate" - switch_options = ("room", "exit", "char", "exact", "loc", "contains") + switch_options = ("room", "exit", "char", "exact", "loc", "startswith") locks = "cmd:perm(find) or perm(Builder)" help_category = "Building" @@ -2360,14 +2360,14 @@ class CmdFind(COMMAND_DEFAULT_CLASS): keyquery = Q(db_key__iexact=searchstring, id__gte=low, id__lte=high) aliasquery = Q(db_tags__db_key__iexact=searchstring, db_tags__db_tagtype__iexact="alias", id__gte=low, id__lte=high) - elif "contains" in switches: - keyquery = Q(db_key__icontains=searchstring, id__gte=low, id__lte=high) - aliasquery = Q(db_tags__db_key__icontains=searchstring, - db_tags__db_tagtype__iexact="alias", id__gte=low, id__lte=high) - else: + elif "startswith" in switches: keyquery = Q(db_key__istartswith=searchstring, id__gte=low, id__lte=high) aliasquery = Q(db_tags__db_key__istartswith=searchstring, db_tags__db_tagtype__iexact="alias", id__gte=low, id__lte=high) + else: + keyquery = Q(db_key__icontains=searchstring, id__gte=low, id__lte=high) + aliasquery = Q(db_tags__db_key__icontains=searchstring, + db_tags__db_tagtype__iexact="alias", id__gte=low, id__lte=high) results = ObjectDB.objects.filter(keyquery | aliasquery).distinct() nresults = results.count() diff --git a/evennia/commands/default/tests.py b/evennia/commands/default/tests.py index 37e4b07b03..f296ca61b6 100644 --- a/evennia/commands/default/tests.py +++ b/evennia/commands/default/tests.py @@ -329,7 +329,7 @@ class TestBuilding(CommandTest): self.call(building.CmdLock(), "Obj = test:perm(Developer)", "Added lock 'test:perm(Developer)' to Obj.") def test_find(self): - self.call(building.CmdFind(), "Room2", "One Match") + self.call(building.CmdFind(), "oom2", "One Match") expect = "One Match(#1#7, loc):\n " +\ "Char2(#7) evennia.objects.objects.DefaultCharacter (location: Room(#1))" self.call(building.CmdFind(), "Char2", expect, cmdstring="locate") @@ -339,7 +339,7 @@ class TestBuilding(CommandTest): self.call(building.CmdFind(), "Char2", expect, cmdstring="@locate") self.call(building.CmdFind(), "/l Char2", expect, cmdstring="find") # /l switch is abbreviated form of /loc self.call(building.CmdFind(), "Char2", "One Match", cmdstring="@find") - self.call(building.CmdFind(), "/contains om2", "One Match") + self.call(building.CmdFind(), "/startswith Room2", "One Match") def test_script(self): self.call(building.CmdScript(), "Obj = scripts.Script", "Script scripts.Script successfully added") From b810ca8a1c6ba5e5e3d94772fe94a5ba54eff484 Mon Sep 17 00:00:00 2001 From: Griatch Date: Sun, 22 Apr 2018 22:28:43 +0200 Subject: [PATCH 113/208] Add check_lockstring as a function in locks/lockhandler.py --- evennia/locks/lockhandler.py | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/evennia/locks/lockhandler.py b/evennia/locks/lockhandler.py index b8801f9655..14ac34a989 100644 --- a/evennia/locks/lockhandler.py +++ b/evennia/locks/lockhandler.py @@ -541,6 +541,42 @@ class LockHandler(object): return all(self._eval_access_type(accessing_obj, locks, access_type) for access_type in locks) +# convenience access function + +# dummy to be able to call check_lockstring from the outside +_LOCK_HANDLER = LockHandler() + + +def check_lockstring(self, accessing_obj, lockstring, no_superuser_bypass=False, + default=False, access_type=None): + """ + Do a direct check against a lockstring ('atype:func()..'), + without any intermediary storage on the accessed object. + + Args: + accessing_obj (object or None): The object seeking access. + Importantly, this can be left unset if the lock functions + don't access it, no updating or storage of locks are made + against this object in this method. + lockstring (str): Lock string to check, on the form + `"access_type:lock_definition"` where the `access_type` + part can potentially be set to a dummy value to just check + a lock condition. + no_superuser_bypass (bool, optional): Force superusers to heed lock. + default (bool, optional): Fallback result to use if `access_type` is set + but no such `access_type` is found in the given `lockstring`. + access_type (str, bool): If set, only this access_type will be looked up + among the locks defined by `lockstring`. + + Return: + access (bool): If check is passed or not. + + """ + return _LOCK_HANDLER.check_lockstring( + accessing_obj, lockstring, no_superuser_bypass=no_superuser_bypass, + default=default, access_type=access_type) + + def _test(): # testing From ebf0fcf0e38bca585ffdf6bf8eed4c32c8306abf Mon Sep 17 00:00:00 2001 From: Tehom Date: Tue, 24 Apr 2018 06:03:01 -0400 Subject: [PATCH 114/208] Call parse_ansi on each returned message before passing to regex to handle individually colored characters. --- evennia/commands/default/tests.py | 9 ++++++--- evennia/contrib/tests.py | 4 ++-- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/evennia/commands/default/tests.py b/evennia/commands/default/tests.py index a0492d6aab..a3be98984a 100644 --- a/evennia/commands/default/tests.py +++ b/evennia/commands/default/tests.py @@ -94,8 +94,11 @@ class CommandTest(EvenniaTest): # Get the first element of a tuple if msg received a tuple instead of a string stored_msg = [smsg[0] if isinstance(smsg, tuple) else smsg for smsg in stored_msg] if msg is not None: - returned_msg = "||".join(_RE.sub("", mess) for mess in stored_msg) - returned_msg = ansi.parse_ansi(returned_msg, strip_ansi=noansi).strip() + # set our separator for returned messages based on parsing ansi or not + msg_sep = "|" if noansi else "||" + # Have to strip ansi for each returned message for the regex to handle it correctly + returned_msg = msg_sep.join(_RE.sub("", ansi.parse_ansi(mess, strip_ansi=noansi)) + for mess in stored_msg).strip() if msg == "" and returned_msg or not returned_msg.startswith(msg.strip()): sep1 = "\n" + "=" * 30 + "Wanted message" + "=" * 34 + "\n" sep2 = "\n" + "=" * 30 + "Returned message" + "=" * 32 + "\n" @@ -166,7 +169,7 @@ class TestSystem(CommandTest): self.call(system.CmdPy(), "1+2", ">>> 1+2|3") def test_scripts(self): - self.call(system.CmdScripts(), "", "| dbref |") + self.call(system.CmdScripts(), "", "dbref ") def test_objects(self): self.call(system.CmdObjects(), "", "Object subtype totals") diff --git a/evennia/contrib/tests.py b/evennia/contrib/tests.py index 78ecc0f4fb..233327a264 100644 --- a/evennia/contrib/tests.py +++ b/evennia/contrib/tests.py @@ -688,8 +688,8 @@ class TestMail(CommandTest): "You have received a new @mail from TestAccount2(account 2)|You sent your message.", caller=self.account2) self.call(mail.CmdMail(), "TestAccount=Message 1", "You sent your message.", caller=self.account2) self.call(mail.CmdMail(), "TestAccount=Message 2", "You sent your message.", caller=self.account2) - self.call(mail.CmdMail(), "", "------------------------------------------------------------------------------| ID: From: Subject:", caller=self.account) - self.call(mail.CmdMail(), "2", "------------------------------------------------------------------------------\nFrom: TestAccount2", caller=self.account) + self.call(mail.CmdMail(), "", "| ID: From: Subject:", caller=self.account) + self.call(mail.CmdMail(), "2", "From: TestAccount2", caller=self.account) self.call(mail.CmdMail(), "/forward TestAccount2 = 1/Forward message", "You sent your message.|Message forwarded.", caller=self.account) self.call(mail.CmdMail(), "/reply 2=Reply Message2", "You sent your message.", caller=self.account) self.call(mail.CmdMail(), "/delete 2", "Message 2 deleted", caller=self.account) From 5335f217185d77a3110ff7227bb86b20f4595e21 Mon Sep 17 00:00:00 2001 From: Griatch Date: Wed, 25 Apr 2018 21:53:28 +0200 Subject: [PATCH 115/208] Update CODING_STYLE.md --- CODING_STYLE.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/CODING_STYLE.md b/CODING_STYLE.md index 79488e94ec..460dfc15d5 100644 --- a/CODING_STYLE.md +++ b/CODING_STYLE.md @@ -97,15 +97,15 @@ def funcname(a, b, c, d=False, **kwargs): Args: a (str): This is a string argument that we can talk about over multiple lines. - b (int or str): Another argument - c (list): A list argument - d (bool, optional): An optional keyword argument + b (int or str): Another argument. + c (list): A list argument. + d (bool, optional): An optional keyword argument. Kwargs: - test (list): A test keyword + test (list): A test keyword. Returns: - e (str): The result of the function + e (str): The result of the function. Raises: RuntimeException: If there is a critical error, From 5edea757c904f5b17ca00a52f492689371a6e9ab Mon Sep 17 00:00:00 2001 From: Griatch Date: Wed, 25 Apr 2018 22:34:33 +0200 Subject: [PATCH 116/208] Fix lockhandler singleton implmentation with a dummyobj --- evennia/locks/lockhandler.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/evennia/locks/lockhandler.py b/evennia/locks/lockhandler.py index 14ac34a989..14556579d7 100644 --- a/evennia/locks/lockhandler.py +++ b/evennia/locks/lockhandler.py @@ -544,7 +544,11 @@ class LockHandler(object): # convenience access function # dummy to be able to call check_lockstring from the outside -_LOCK_HANDLER = LockHandler() + +class _ObjDummy: + lock_storage = '' + +_LOCK_HANDLER = LockHandler(_ObjDummy()) def check_lockstring(self, accessing_obj, lockstring, no_superuser_bypass=False, From c298f1182fbae620da0cf4e288b0d8cde9434a5f Mon Sep 17 00:00:00 2001 From: Griatch Date: Thu, 10 May 2018 20:37:21 +0200 Subject: [PATCH 117/208] Auto-tag spawned objects. Clean up unit tests --- evennia/settings_default.py | 6 +- evennia/utils/inlinefuncs.py | 11 +- evennia/utils/spawner.py | 272 +++++++++++++++++++++------- evennia/utils/tests/test_spawner.py | 48 +++-- 4 files changed, 251 insertions(+), 86 deletions(-) diff --git a/evennia/settings_default.py b/evennia/settings_default.py index a5c4b7255d..1d7adb4375 100644 --- a/evennia/settings_default.py +++ b/evennia/settings_default.py @@ -513,7 +513,7 @@ TIME_GAME_EPOCH = None TIME_IGNORE_DOWNTIMES = False ###################################################################### -# Inlinefunc +# Inlinefunc & PrototypeFuncs ###################################################################### # Evennia supports inline function preprocessing. This allows users # to supply inline calls on the form $func(arg, arg, ...) to do @@ -525,6 +525,10 @@ INLINEFUNC_ENABLED = False # is loaded from left-to-right, same-named functions will overload INLINEFUNC_MODULES = ["evennia.utils.inlinefuncs", "server.conf.inlinefuncs"] +# Module holding handlers for OLCFuncs. These allow for embedding +# functional code in prototypes +PROTOTYPEFUNC_MODULES = ["evennia.utils.prototypefuncs", + "server.conf.prototypefuncs"] ###################################################################### # Default Account setup and access diff --git a/evennia/utils/inlinefuncs.py b/evennia/utils/inlinefuncs.py index e103e217d7..2646fb3991 100644 --- a/evennia/utils/inlinefuncs.py +++ b/evennia/utils/inlinefuncs.py @@ -257,7 +257,7 @@ class InlinefuncError(RuntimeError): pass -def parse_inlinefunc(string, strip=False, **kwargs): +def parse_inlinefunc(string, strip=False, _available_funcs=None, **kwargs): """ Parse the incoming string. @@ -265,6 +265,8 @@ def parse_inlinefunc(string, strip=False, **kwargs): string (str): The incoming string to parse. strip (bool, optional): Whether to strip function calls rather than execute them. + _available_funcs(dict, optional): Define an alterinative source of functions to parse for. + If unset, use the functions found through `settings.INLINEFUNC_MODULES`. Kwargs: session (Session): This is sent to this function by Evennia when triggering it. It is passed to the inlinefunc. @@ -273,6 +275,9 @@ def parse_inlinefunc(string, strip=False, **kwargs): """ global _PARSING_CACHE + + _available_funcs = _INLINE_FUNCS if _available_funcs is None else _available_funcs + if string in _PARSING_CACHE: # stack is already cached stack = _PARSING_CACHE[string] @@ -309,9 +314,9 @@ def parse_inlinefunc(string, strip=False, **kwargs): funcname = _RE_STARTTOKEN.match(gdict["start"]).group(1) try: # try to fetch the matching inlinefunc from storage - stack.append(_INLINE_FUNCS[funcname]) + stack.append(_available_funcs[funcname]) except KeyError: - stack.append(_INLINE_FUNCS["nomatch"]) + stack.append(_available_funcs["nomatch"]) stack.append(funcname) ncallable += 1 elif gdict["escaped"]: diff --git a/evennia/utils/spawner.py b/evennia/utils/spawner.py index 1916c2210e..daf4b23c3f 100644 --- a/evennia/utils/spawner.py +++ b/evennia/utils/spawner.py @@ -22,28 +22,41 @@ GOBLIN = { ``` Possible keywords are: - prototype - string parent prototype - key - string, the main object identifier - typeclass - string, if not set, will use `settings.BASE_OBJECT_TYPECLASS` - location - this should be a valid object or #dbref - home - valid object or #dbref - destination - only valid for exits (object or dbref) + prototype_key (str): name of this prototype. This is used when storing prototypes and should + be unique. This should always be defined but for prototypes defined in modules, the + variable holding the prototype dict will become the prototype_key if it's not explicitly + given. + prototype_desc (str, optional): describes prototype in listings + prototype_locks (str, optional): locks for restricting access to this prototype. Locktypes + supported are 'edit' and 'use'. + prototype_tags(list, optional): List of tags or tuples (tag, category) used to group prototype + in listings - permissions - string or list of permission strings - locks - a lock-string - aliases - string or list of strings - exec - this is a string of python code to execute or a list of such codes. - This can be used e.g. to trigger custom handlers on the object. The - execution namespace contains 'evennia' for the library and 'obj' - tags - string or list of strings or tuples `(tagstr, category)`. Plain - strings will be result in tags with no category (default tags). - attrs - tuple or list of tuples of Attributes to add. This form allows - more complex Attributes to be set. Tuples at least specify `(key, value)` - but can also specify up to `(key, value, category, lockstring)`. If - you want to specify a lockstring but not a category, set the category - to `None`. - ndb_ - value of a nattribute (ndb_ is stripped) - other - any other name is interpreted as the key of an Attribute with + prototype (str or callable, optional): bame (prototype_key) of eventual parent prototype + typeclass (str or callable, optional): if not set, will use typeclass of parent prototype or use + `settings.BASE_OBJECT_TYPECLASS` + key (str or callable, optional): the name of the spawned object. If not given this will set to a + random hash + location (obj, str or callable, optional): location of the object - a valid object or #dbref + home (obj, str or callable, optional): valid object or #dbref + destination (obj, str or callable, optional): only valid for exits (object or #dbref) + + permissions (str, list or callable, optional): which permissions for spawned object to have + locks (str or callable, optional): lock-string for the spawned object + aliases (str, list or callable, optional): Aliases for the spawned object + exec (str or callable, optional): this is a string of python code to execute or a list of such + codes. This can be used e.g. to trigger custom handlers on the object. The execution + namespace contains 'evennia' for the library and 'obj'. All default spawn commands limit + this functionality to Developer/superusers. Usually it's better to use callables or + prototypefuncs instead of this. + tags (str, tuple, list or callable, optional): string or list of strings or tuples + `(tagstr, category)`. Plain strings will be result in tags with no category (default tags). + attrs (tuple, list or callable, optional): tuple or list of tuples of Attributes to add. This + form allows more complex Attributes to be set. Tuples at least specify `(key, value)` + but can also specify up to `(key, value, category, lockstring)`. If you want to specify a + lockstring but not a category, set the category to `None`. + ndb_ (any): value of a nattribute (ndb_ is stripped) + other (any): any other name is interpreted as the key of an Attribute with its value. Such Attributes have no categories. Each value can also be a callable that takes no arguments. It should @@ -56,6 +69,9 @@ that prototype, inheritng all prototype slots it does not explicitly define itself, while overloading those that it does specify. ```python +import random + + GOBLIN_WIZARD = { "prototype": GOBLIN, "key": "goblin wizard", @@ -65,6 +81,7 @@ GOBLIN_WIZARD = { GOBLIN_ARCHER = { "prototype": GOBLIN, "key": "goblin archer", + "attack_skill": (random, (5, 10))" "attacks": ["short bow"] } ``` @@ -105,15 +122,18 @@ prototype, override its name with an empty dict. from __future__ import print_function import copy +import hashlib +import time from ast import literal_eval from django.conf import settings from random import randint import evennia from evennia.objects.models import ObjectDB from evennia.utils.utils import ( - make_iter, all_from_module, dbid_to_obj, is_iter, crop, get_all_typeclasses) + make_iter, all_from_module, callables_from_module, dbid_to_obj, + is_iter, crop, get_all_typeclasses) +from evennia.utils import inlinefuncs -from collections import namedtuple from evennia.scripts.scripts import DefaultScript from evennia.utils.create import create_script from evennia.utils.evtable import EvTable @@ -126,7 +146,9 @@ _PROTOTYPE_META_NAMES = ("prototype_key", "prototype_desc", "prototype_tags", "p _NON_CREATE_KWARGS = _CREATE_OBJECT_KWARGS + _PROTOTYPE_META_NAMES _MODULE_PROTOTYPES = {} _MODULE_PROTOTYPE_MODULES = {} +_PROTOTYPEFUNCS = {} _MENU_CROP_WIDTH = 15 +_PROTOTYPE_TAG_CATEGORY = "spawned_by_prototype" _MENU_ATTR_LITERAL_EVAL_ERROR = ( "|rCritical Python syntax error in your value. Only primitive Python structures are allowed.\n" @@ -138,6 +160,9 @@ class PermissionError(RuntimeError): pass +# load resources + + for mod in settings.PROTOTYPE_MODULES: # to remove a default prototype, override it with an empty dict. # internally we store as (key, desc, locks, tags, prototype_dict) @@ -148,7 +173,7 @@ for mod in settings.PROTOTYPE_MODULES: # make sure the prototype contains all meta info for prototype_key, prot in prots: prot.update({ - "prototype_key": prototype_key.lower(), + "prototype_key": prot.get('prototype_key', prototype_key.lower()), "prototype_desc": prot['prototype_desc'] if 'prototype_desc' in prot else mod, "prototype_locks": prot['prototype_locks'] if 'prototype_locks' in prot else "use:all()", "prototype_tags": set(make_iter(prot['prototype_tags']) @@ -156,6 +181,81 @@ for mod in settings.PROTOTYPE_MODULES: _MODULE_PROTOTYPES[prototype_key] = prot +for mod in settings.PROTOTYPEFUNC_MODULES: + try: + _PROTOTYPEFUNCS.update(callables_from_module(mod)) + except ImportError: + pass + + +# Helper functions + + +def olcfunc_parser(value, available_functions=None, **kwargs): + """ + This is intended to be used by the in-game olc mechanism. It will parse the prototype + value for function tokens like `$olcfunc(arg, arg, ...)`. These functions behave all the + parameters of `inlinefuncs` but they are *not* passed a Session since this is not guaranteed to + be available at the time of spawning. They may also return other structures than strings. + + Available olcfuncs are specified as callables in one of the modules of + `settings.PROTOTYPEFUNC_MODULES`, or specified on the command line. + + Args: + value (string): The value to test for a parseable olcfunc. + available_functions (dict, optional): Mapping of name:olcfunction to use for this parsing. + + Kwargs: + any (any): Passed on to the inlinefunc. + + Returns: + any (any): A structure to replace the string on the prototype level. If this is a + callable or a (callable, (args,)) structure, it will be executed as if one had supplied + it to the prototype directly. + + """ + if not isinstance(basestring, value): + return value + available_functions = _PROTOTYPEFUNCS if available_functions is None else available_functions + return inlinefuncs.parse_inlinefunc(value, _available_funcs=available_functions) + + +def _to_obj(value, force=True): + return dbid_to_obj(value, ObjectDB) + + +def _to_obj_or_any(value): + obj = dbid_to_obj(value, ObjectDB) + return obj if obj is not None else value + + +def validate_spawn_value(value, validator=None): + """ + Analyze the value and produce a value for use at the point of spawning. + + Args: + value (any): This can be:j + callable - will be called as callable() + (callable, (args,)) - will be called as callable(*args) + other - will be assigned depending on the variable type + validator (callable, optional): If given, this will be called with the value to + check and guarantee the outcome is of a given type. + + Returns: + any (any): The (potentially pre-processed value to use for this prototype key) + + """ + validator = validator if validator else lambda o: o + if callable(value): + return validator(value()) + elif value and is_iter(value) and callable(value[0]): + # a structure (callable, (args, )) + args = value[1:] + return validator(value[0](*make_iter(args))) + else: + return validator(value) + + # Prototype storage mechanisms @@ -384,6 +484,20 @@ def search_prototype(key=None, tags=None): return matches +def search_objects_with_prototype(prototype_key): + """ + Retrieve all object instances created by a given prototype. + + Args: + prototype_key (str): The exact (and unique) prototype identifier to query for. + + Returns: + matches (Queryset): All matching objects spawned from this prototype. + + """ + return ObjectDB.objects.get_by_tag(key=prototype_key, category=_PROTOTYPE_TAG_CATEGORY) + + def get_protparent_dict(): """ Get prototype parents. @@ -401,7 +515,7 @@ def list_prototypes(caller, key=None, tags=None, show_non_use=False, show_non_ed Args: caller (Account or Object): The object requesting the list. - key (str, optional): Exact or partial key to query for. + key (str, optional): Exact or partial prototype key to query for. tags (str or list, optional): Tag key or keys to query for. show_non_use (bool, optional): Show also prototypes the caller may not use. show_non_edit (bool, optional): Show also prototypes the caller may not edit. @@ -427,23 +541,34 @@ def list_prototypes(caller, key=None, tags=None, show_non_use=False, show_non_ed caller, prototype.get('prototype_locks', ''), access_type='edit') if not show_non_edit and not lock_edit: continue + ptags = [] + for ptag in prototype.get('prototype_tags', []): + if is_iter(ptag): + if len(ptag) > 1: + ptags.append("{} (category: {}".format(ptag[0], ptag[1])) + else: + ptags.append(ptag[0]) + else: + ptags.append(str(ptag)) + display_tuples.append( (prototype.get('prototype_key', ''), prototype.get('prototype_desc', ''), "{}/{}".format('Y' if lock_use else 'N', 'Y' if lock_edit else 'N'), - ",".join(prototype.get('prototype_tags', [])))) + ",".join(ptags))) if not display_tuples: return None table = [] + width = 78 for i in range(len(display_tuples[0])): table.append([str(display_tuple[i]) for display_tuple in display_tuples]) - table = EvTable("Key", "Desc", "Use/Edit", "Tags", table=table, crop=True, width=78) - table.reformat_column(0, width=28) - table.reformat_column(1, width=40) - table.reformat_column(2, width=11, align='r') - table.reformat_column(3, width=20) + table = EvTable("Key", "Desc", "Use/Edit", "Tags", table=table, crop=True, width=width) + table.reformat_column(0, width=22) + table.reformat_column(1, width=31) + table.reformat_column(2, width=9, align='r') + table.reformat_column(3, width=16) return table @@ -472,17 +597,14 @@ def prototype_to_str(prototype): # Spawner mechanism -def _handle_dbref(inp): - return dbid_to_obj(inp, ObjectDB) - - def validate_prototype(prototype, protkey=None, protparents=None, _visited=None): """ Run validation on a prototype, checking for inifinite regress. Args: prototype (dict): Prototype to validate. - protkey (str, optional): The name of the prototype definition, if any. + protkey (str, optional): The name of the prototype definition. If not given, the prototype + dict needs to have the `prototype_key` field set. protpartents (dict, optional): The available prototype parent library. If note given this will be determined from settings/database. _visited (list, optional): This is an internal work array and should not be set manually. @@ -494,9 +616,8 @@ def validate_prototype(prototype, protkey=None, protparents=None, _visited=None) protparents = get_protparent_dict() if _visited is None: _visited = [] - protkey = protkey or prototype.get('prototype_key', None) - protkey = protkey.lower() or prototype.get('prototype_key', None) + protkey = protkey and protkey.lower() or prototype.get('prototype_key', "") assert isinstance(prototype, dict) @@ -619,9 +740,12 @@ def spawn(*prototypes, **kwargs): return_prototypes (bool): Only return a list of the prototype-parents (no object creation happens) + Returns: + object (Object): Spawned object. + """ # get available protparents - protparents = get_protparents() + protparents = get_protparent_dict() # overload module's protparents with specifically given protparents protparents.update(kwargs.get("prototype_parents", {})) @@ -643,47 +767,61 @@ def spawn(*prototypes, **kwargs): # extract the keyword args we need to create the object itself. If we get a callable, # call that to get the value (don't catch errors) create_kwargs = {} - keyval = prot.pop("key", "Spawned Object %06i" % randint(1, 100000)) - create_kwargs["db_key"] = keyval() if callable(keyval) else keyval + # we must always add a key, so if not given we use a shortened md5 hash. There is a (small) + # chance this is not unique but it should usually not be a problem. + val = prot.pop("key", "Spawned-{}".format( + hashlib.md5(str(time.time())).hexdigest()[:6])) + create_kwargs["db_key"] = validate_spawn_value(val, str) - locval = prot.pop("location", None) - create_kwargs["db_location"] = locval() if callable(locval) else _handle_dbref(locval) + val = prot.pop("location", None) + create_kwargs["db_location"] = validate_spawn_value(val, _to_obj) - homval = prot.pop("home", settings.DEFAULT_HOME) - create_kwargs["db_home"] = homval() if callable(homval) else _handle_dbref(homval) + val = prot.pop("home", settings.DEFAULT_HOME) + create_kwargs["db_home"] = validate_spawn_value(val, _to_obj) - destval = prot.pop("destination", None) - create_kwargs["db_destination"] = destval() if callable(destval) else _handle_dbref(destval) + val = prot.pop("destination", None) + create_kwargs["db_destination"] = validate_spawn_value(val, _to_obj) - typval = prot.pop("typeclass", settings.BASE_OBJECT_TYPECLASS) - create_kwargs["db_typeclass_path"] = typval() if callable(typval) else typval + val = prot.pop("typeclass", settings.BASE_OBJECT_TYPECLASS) + create_kwargs["db_typeclass_path"] = validate_spawn_value(val, str) # extract calls to handlers - permval = prot.pop("permissions", []) - permission_string = permval() if callable(permval) else permval - lockval = prot.pop("locks", "") - lock_string = lockval() if callable(lockval) else lockval - aliasval = prot.pop("aliases", "") - alias_string = aliasval() if callable(aliasval) else aliasval - tagval = prot.pop("tags", []) - tags = tagval() if callable(tagval) else tagval + val = prot.pop("permissions", []) + permission_string = validate_spawn_value(val, make_iter) + val = prot.pop("locks", "") + lock_string = validate_spawn_value(val, str) + val = prot.pop("aliases", []) + alias_string = validate_spawn_value(val, make_iter) + + val = prot.pop("tags", []) + tags = validate_spawn_value(val, make_iter) # we make sure to add a tag identifying which prototype created this object - # tags.append(()) + tags.append((prototype['prototype_key'], _PROTOTYPE_TAG_CATEGORY)) - attrval = prot.pop("attrs", []) - attributes = attrval() if callable(tagval) else attrval - - exval = prot.pop("exec", "") - execs = make_iter(exval() if callable(exval) else exval) + val = prot.pop("exec", "") + execs = validate_spawn_value(val, make_iter) # extract ndb assignments - nattributes = dict((key.split("_", 1)[1], value() if callable(value) else value) - for key, value in prot.items() if key.startswith("ndb_")) + nattributes = dict((key.split("_", 1)[1], validate_spawn_value(val, _to_obj)) + for key, val in prot.items() if key.startswith("ndb_")) # the rest are attributes - simple_attributes = [(key, value()) if callable(value) else (key, value) - for key, value in prot.items() if not (key.startswith("ndb_"))] + val = prot.pop("attrs", []) + attributes = validate_spawn_value(val, list) + + simple_attributes = [] + for key, value in ((key, value) for key, value in prot.items() + if not (key.startswith("ndb_"))): + if is_iter(value) and len(value) > 1: + # (value, category) + simple_attributes.append((key, + validate_spawn_value(value[0], _to_obj_or_any), + validate_spawn_value(value[1], str))) + else: + simple_attributes.append((key, + validate_spawn_value(value, _to_obj_or_any))) + attributes = attributes + simple_attributes attributes = [tup for tup in attributes if not tup[0] in _NON_CREATE_KWARGS] diff --git a/evennia/utils/tests/test_spawner.py b/evennia/utils/tests/test_spawner.py index e29ee8c151..4d680a9e8a 100644 --- a/evennia/utils/tests/test_spawner.py +++ b/evennia/utils/tests/test_spawner.py @@ -7,16 +7,29 @@ from evennia.utils.test_resources import EvenniaTest from evennia.utils import spawner +class TestSpawner(EvenniaTest): + + def setUp(self): + super(TestSpawner, self).setUp() + self.prot1 = {"prototype_key": "testprototype"} + + def test_spawn(self): + obj1 = spawner.spawn(self.prot1) + # check spawned objects have the right tag + self.assertEqual(list(spawner.search_objects_with_prototype("testprototype")), obj1) + + class TestPrototypeStorage(EvenniaTest): def setUp(self): super(TestPrototypeStorage, self).setUp() - self.prot1 = {"key": "testprototype"} - self.prot2 = {"key": "testprototype2"} - self.prot3 = {"key": "testprototype3"} + self.prot1 = {"prototype_key": "testprototype"} + self.prot2 = {"prototype_key": "testprototype2"} + self.prot3 = {"prototype_key": "testprototype3"} def _get_metaproto( - self, key='testprototype', desc='testprototype', locks=['edit:id(6) or perm(Admin)', 'use:all()'], + self, key='testprototype', desc='testprototype', + locks=['edit:id(6) or perm(Admin)', 'use:all()'], tags=[], prototype={"key": "testprototype"}): return spawner.build_metaproto(key, desc, locks, tags, prototype) @@ -28,34 +41,39 @@ class TestPrototypeStorage(EvenniaTest): def test_prototype_storage(self): - prot = spawner.save_db_prototype(self.char1, "testprot", self.prot1, desc='testdesc0', tags=["foo"]) + prot = spawner.save_db_prototype(self.char1, self.prot1, "testprot", + desc='testdesc0', tags=["foo"]) self.assertTrue(bool(prot)) self.assertEqual(prot.db.prototype, self.prot1) self.assertEqual(prot.desc, "testdesc0") - prot = spawner.save_db_prototype(self.char1, "testprot", self.prot1, desc='testdesc', tags=["fooB"]) + prot = spawner.save_db_prototype(self.char1, self.prot1, "testprot", + desc='testdesc', tags=["fooB"]) self.assertEqual(prot.db.prototype, self.prot1) self.assertEqual(prot.desc, "testdesc") self.assertTrue(bool(prot.tags.get("fooB", "db_prototype"))) self.assertEqual(list(prot.__class__.objects.get_by_tag("foo", "db_prototype")), [prot]) - prot2 = spawner.save_db_prototype(self.char1, "testprot2", self.prot2, desc='testdesc2b', tags=["foo"]) - self.assertEqual(list(prot.__class__.objects.get_by_tag("foo", "db_prototype")), [prot, prot2]) + prot2 = spawner.save_db_prototype(self.char1, self.prot2, "testprot2", + desc='testdesc2b', tags=["foo"]) + self.assertEqual( + list(prot.__class__.objects.get_by_tag("foo", "db_prototype")), [prot, prot2]) - prot3 = spawner.save_db_prototype(self.char1, "testprot2", self.prot3, desc='testdesc2') + prot3 = spawner.save_db_prototype(self.char1, self.prot3, "testprot2", desc='testdesc2') self.assertEqual(prot2.id, prot3.id) - self.assertEqual(list(prot.__class__.objects.get_by_tag("foo", "db_prototype")), [prot, prot2]) + self.assertEqual( + list(prot.__class__.objects.get_by_tag("foo", "db_prototype")), [prot, prot2]) # returns DBPrototype - self.assertEqual(list(spawner.search_db_prototype("testprot")), [prot]) + self.assertEqual(list(spawner.search_db_prototype("testprot", return_queryset=True)), [prot]) - # returns metaprotos - prot = self._to_metaproto(prot) - prot3 = self._to_metaproto(prot3) + prot = prot.db.prototype + prot3 = prot3.db.prototype self.assertEqual(list(spawner.search_prototype("testprot")), [prot]) - self.assertEqual(list(spawner.search_prototype("testprot", return_meta=False)), [self.prot1]) + self.assertEqual( + list(spawner.search_prototype("testprot")), [self.prot1]) # partial match self.assertEqual(list(spawner.search_prototype("prot")), [prot, prot3]) self.assertEqual(list(spawner.search_prototype(tags="foo")), [prot, prot3]) From 02067583bd1e6b14241edc1837dbbd3e69fa18ed Mon Sep 17 00:00:00 2001 From: Griatch Date: Thu, 10 May 2018 22:28:16 +0200 Subject: [PATCH 118/208] Work to test functionality --- evennia/commands/default/building.py | 8 +++++++- evennia/commands/default/tests.py | 5 +++-- evennia/utils/spawner.py | 30 +++++++++++++++++----------- 3 files changed, 28 insertions(+), 15 deletions(-) diff --git a/evennia/commands/default/building.py b/evennia/commands/default/building.py index 8616b7dafa..b41b5c40e9 100644 --- a/evennia/commands/default/building.py +++ b/evennia/commands/default/building.py @@ -2840,8 +2840,14 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): |wdestination|n - only valid for exits (object or dbref) |wpermissions|n - string or list of permission strings |wlocks |n - a lock-string - |waliases |n - string or list of strings + |waliases |n - string or list of strings. |wndb_|n - value of a nattribute (ndb_ is stripped) + + |wprototype_key|n - name of this prototype. Used to store/retrieve from db + |wprototype_desc|n - desc of this prototype. Used in listings + |wprototype_locks|n - locks of this prototype. Limits who may use prototype + |wprototype_tags|n - tags of this prototype. Used to find prototype + any other keywords are interpreted as Attributes and their values. The available prototypes are defined globally in modules set in diff --git a/evennia/commands/default/tests.py b/evennia/commands/default/tests.py index 950b934125..ffb877c3e3 100644 --- a/evennia/commands/default/tests.py +++ b/evennia/commands/default/tests.py @@ -369,7 +369,8 @@ class TestBuilding(CommandTest): # Tests "@spawn " without specifying location. self.call(building.CmdSpawn(), - "{'key':'goblin', 'typeclass':'evennia.DefaultCharacter'}", "Spawned goblin") + "{'prototype_key': 'testprot', 'key':'goblin', " + "'typeclass':'evennia.DefaultCharacter'}", "Spawned goblin") goblin = getObject(self, "goblin") # Tests that the spawned object's type is a DefaultCharacter. @@ -394,7 +395,7 @@ class TestBuilding(CommandTest): self.assertEqual(goblin.location, spawnLoc) goblin.delete() - spawner.save_db_prototype(self.char1, "ball", {'key': 'Ball', 'prototype': 'GOBLIN'}) + spawner.save_db_prototype(self.char1, {'key': 'Ball', 'prototype': 'GOBLIN'}, 'ball') # Tests "@spawn " self.call(building.CmdSpawn(), "ball", "Spawned Ball") diff --git a/evennia/utils/spawner.py b/evennia/utils/spawner.py index daf4b23c3f..335eea9341 100644 --- a/evennia/utils/spawner.py +++ b/evennia/utils/spawner.py @@ -172,13 +172,14 @@ for mod in settings.PROTOTYPE_MODULES: _MODULE_PROTOTYPE_MODULES.update({prototype_key: mod for prototype_key, _ in prots}) # make sure the prototype contains all meta info for prototype_key, prot in prots: + actual_prot_key = prot.get('prototype_key', prototype_key).lower() prot.update({ - "prototype_key": prot.get('prototype_key', prototype_key.lower()), + "prototype_key": actual_prot_key, "prototype_desc": prot['prototype_desc'] if 'prototype_desc' in prot else mod, - "prototype_locks": prot['prototype_locks'] if 'prototype_locks' in prot else "use:all()", - "prototype_tags": set(make_iter(prot['prototype_tags']) - if 'prototype_tags' in prot else ["base-prototype"])}) - _MODULE_PROTOTYPES[prototype_key] = prot + "prototype_locks": (prot['prototype_locks'] + if 'prototype_locks' in prot else "use:all();edit:false()"), + "prototype_tags": list(set(make_iter(prot.get('prototype_tags', [])) + ["module"]))}) + _MODULE_PROTOTYPES[actual_prot_key] = prot for mod in settings.PROTOTYPEFUNC_MODULES: @@ -537,8 +538,11 @@ def list_prototypes(caller, key=None, tags=None, show_non_use=False, show_non_ed caller, prototype.get('prototype_locks', ''), access_type='use') if not show_non_use and not lock_use: continue - lock_edit = caller.locks.check_lockstring( - caller, prototype.get('prototype_locks', ''), access_type='edit') + if prototype.get('prototype_key', '') in _MODULE_PROTOTYPES: + lock_edit = False + else: + lock_edit = caller.locks.check_lockstring( + caller, prototype.get('prototype_locks', ''), access_type='edit') if not show_non_edit and not lock_edit: continue ptags = [] @@ -566,8 +570,8 @@ def list_prototypes(caller, key=None, tags=None, show_non_use=False, show_non_ed table.append([str(display_tuple[i]) for display_tuple in display_tuples]) table = EvTable("Key", "Desc", "Use/Edit", "Tags", table=table, crop=True, width=width) table.reformat_column(0, width=22) - table.reformat_column(1, width=31) - table.reformat_column(2, width=9, align='r') + table.reformat_column(1, width=29) + table.reformat_column(2, width=11, align='c') table.reformat_column(3, width=16) return table @@ -617,7 +621,7 @@ def validate_prototype(prototype, protkey=None, protparents=None, _visited=None) if _visited is None: _visited = [] - protkey = protkey and protkey.lower() or prototype.get('prototype_key', "") + protkey = protkey and protkey.lower() or prototype.get('prototype_key', None) assert isinstance(prototype, dict) @@ -796,8 +800,10 @@ def spawn(*prototypes, **kwargs): val = prot.pop("tags", []) tags = validate_spawn_value(val, make_iter) - # we make sure to add a tag identifying which prototype created this object - tags.append((prototype['prototype_key'], _PROTOTYPE_TAG_CATEGORY)) + prototype_key = prototype.get('prototype_key', None) + if prototype_key: + # we make sure to add a tag identifying which prototype created this object + tags.append((prototype_key, _PROTOTYPE_TAG_CATEGORY)) val = prot.pop("exec", "") execs = validate_spawn_value(val, make_iter) From 59d136f4053543fd59c2205a229c8a0682fdb909 Mon Sep 17 00:00:00 2001 From: Tehom Date: Thu, 10 May 2018 19:23:59 -0400 Subject: [PATCH 119/208] Add special handling for scripts when flushed from cache to avoid duplicate ExtendedLoopingCalls. --- evennia/scripts/scripts.py | 46 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 44 insertions(+), 2 deletions(-) diff --git a/evennia/scripts/scripts.py b/evennia/scripts/scripts.py index db6e9652cb..eafb87ee72 100644 --- a/evennia/scripts/scripts.py +++ b/evennia/scripts/scripts.py @@ -18,6 +18,17 @@ from future.utils import with_metaclass __all__ = ["DefaultScript", "DoNothing", "Store"] +FLUSHING_INSTANCES = False # whether we're in the process of flushing scripts from the cache +SCRIPT_FLUSH_TIMERS = {} # stores timers for scripts that are currently being flushed + + +def restart_scripts_after_flush(): + """After instances are flushed, validate scripts so they're not dead for a long period of time""" + global FLUSHING_INSTANCES + ScriptDB.objects.validate() + FLUSHING_INSTANCES = False + + class ExtendedLoopingCall(LoopingCall): """ LoopingCall that can start at a delay different @@ -278,6 +289,27 @@ class DefaultScript(ScriptBase): return max(0, self.db_repeats - task.callcount) return None + def at_idmapper_flush(self): + """If we're flushing this object, make sure the LoopingCall is gone too""" + ret = super(DefaultScript, self).at_idmapper_flush() + if ret: + try: + from twisted.internet import reactor + global FLUSHING_INSTANCES + # store the current timers for the _task and stop it to avoid duplicates after cache flush + paused_time = self.ndb._task.next_call_time() + callcount = self.ndb._task.callcount + self._stop_task() + SCRIPT_FLUSH_TIMERS[self.id] = (paused_time, callcount) + # here we ensure that the restart call only happens once, not once per script + if not FLUSHING_INSTANCES: + FLUSHING_INSTANCES = True + reactor.callLater(2, restart_scripts_after_flush) + except Exception: + import traceback + traceback.print_exc() + return ret + def start(self, force_restart=False): """ Called every time the script is started (for persistent @@ -294,9 +326,19 @@ class DefaultScript(ScriptBase): started or not. Used in counting. """ - if self.is_active and not force_restart: - # script already runs and should not be restarted. + # The script is already running, but make sure we have a _task if this is after a cache flush + if not self.ndb._task: + self.ndb._task = ExtendedLoopingCall(self._step_task) + try: + start_delay, callcount = SCRIPT_FLUSH_TIMERS[self.id] + del SCRIPT_FLUSH_TIMERS[self.id] + now = False + except (KeyError, ValueError, TypeError): + now = not self.db_start_delay + start_delay = None + callcount = 0 + self.ndb._task.start(self.db_interval, now=now, start_delay=start_delay, count_start=callcount) return 0 obj = self.obj From 9ee420458c8a4e6b9f0f3047280b75511a4a3ae5 Mon Sep 17 00:00:00 2001 From: Tehom Date: Thu, 10 May 2018 19:30:58 -0400 Subject: [PATCH 120/208] Fix paused tasks. --- evennia/scripts/scripts.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/evennia/scripts/scripts.py b/evennia/scripts/scripts.py index eafb87ee72..2cade33281 100644 --- a/evennia/scripts/scripts.py +++ b/evennia/scripts/scripts.py @@ -292,7 +292,7 @@ class DefaultScript(ScriptBase): def at_idmapper_flush(self): """If we're flushing this object, make sure the LoopingCall is gone too""" ret = super(DefaultScript, self).at_idmapper_flush() - if ret: + if ret and self.ndb._task: try: from twisted.internet import reactor global FLUSHING_INSTANCES From 058c65074e3769f2666c1c1f97c7d1704f469bd6 Mon Sep 17 00:00:00 2001 From: Tehom Date: Thu, 10 May 2018 19:40:58 -0400 Subject: [PATCH 121/208] Handle scripts with negative intervals. --- evennia/scripts/scripts.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/evennia/scripts/scripts.py b/evennia/scripts/scripts.py index 2cade33281..2b4bea3569 100644 --- a/evennia/scripts/scripts.py +++ b/evennia/scripts/scripts.py @@ -328,7 +328,7 @@ class DefaultScript(ScriptBase): """ if self.is_active and not force_restart: # The script is already running, but make sure we have a _task if this is after a cache flush - if not self.ndb._task: + if not self.ndb._task and self.db_interval >= 0: self.ndb._task = ExtendedLoopingCall(self._step_task) try: start_delay, callcount = SCRIPT_FLUSH_TIMERS[self.id] From 78ce1d21028c9004ae03291898960d69a420cc2f Mon Sep 17 00:00:00 2001 From: Griatch Date: Sat, 12 May 2018 12:19:47 +0200 Subject: [PATCH 122/208] Fix unit tests --- evennia/utils/spawner.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/evennia/utils/spawner.py b/evennia/utils/spawner.py index 335eea9341..c63ddaf868 100644 --- a/evennia/utils/spawner.py +++ b/evennia/utils/spawner.py @@ -388,7 +388,7 @@ def search_db_prototype(key=None, tags=None, return_queryset=False): tags (str or list): Tag key or keys to query for. These will always be applied with the 'db_protototype' tag category. - return_queryset (bool): Return the database queryset. + return_queryset (bool, optional): Return the database queryset. Return: matches (queryset or list): All found DbPrototypes. If `return_queryset` is not set, this is a list of prototype dicts. @@ -410,7 +410,7 @@ def search_db_prototype(key=None, tags=None, return_queryset=False): matches = matches.filter(db_key=key) or matches.filter(db_key__icontains=key) if not return_queryset: # return prototype - return [dbprot.attributes.get("prototype", {}) for dbprot in matches] + matches = [dict(dbprot.attributes.get("prototype", {})) for dbprot in matches] return matches From 436ad4d8a588b845e3d6de3cb7d30c37e8f17423 Mon Sep 17 00:00:00 2001 From: Griatch Date: Sun, 13 May 2018 14:50:48 +0200 Subject: [PATCH 123/208] Unittests pass --- evennia/utils/spawner.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/evennia/utils/spawner.py b/evennia/utils/spawner.py index c63ddaf868..aa62a1dcad 100644 --- a/evennia/utils/spawner.py +++ b/evennia/utils/spawner.py @@ -192,19 +192,19 @@ for mod in settings.PROTOTYPEFUNC_MODULES: # Helper functions -def olcfunc_parser(value, available_functions=None, **kwargs): +def protfunc_parser(value, available_functions=None, **kwargs): """ This is intended to be used by the in-game olc mechanism. It will parse the prototype - value for function tokens like `$olcfunc(arg, arg, ...)`. These functions behave all the + value for function tokens like `$protfunc(arg, arg, ...)`. These functions behave all the parameters of `inlinefuncs` but they are *not* passed a Session since this is not guaranteed to be available at the time of spawning. They may also return other structures than strings. - Available olcfuncs are specified as callables in one of the modules of + Available protfuncs are specified as callables in one of the modules of `settings.PROTOTYPEFUNC_MODULES`, or specified on the command line. Args: - value (string): The value to test for a parseable olcfunc. - available_functions (dict, optional): Mapping of name:olcfunction to use for this parsing. + value (string): The value to test for a parseable protfunc. + available_functions (dict, optional): Mapping of name:protfunction to use for this parsing. Kwargs: any (any): Passed on to the inlinefunc. @@ -215,7 +215,7 @@ def olcfunc_parser(value, available_functions=None, **kwargs): it to the prototype directly. """ - if not isinstance(basestring, value): + if not isinstance(value, basestring): return value available_functions = _PROTOTYPEFUNCS if available_functions is None else available_functions return inlinefuncs.parse_inlinefunc(value, _available_funcs=available_functions) @@ -246,6 +246,7 @@ def validate_spawn_value(value, validator=None): any (any): The (potentially pre-processed value to use for this prototype key) """ + value = protfunc_parser(value) validator = validator if validator else lambda o: o if callable(value): return validator(value()) From eeeef27283b6b90d707c5d0ee9a6811a2827d216 Mon Sep 17 00:00:00 2001 From: Griatch Date: Tue, 15 May 2018 15:42:04 +0200 Subject: [PATCH 124/208] Start work on prototype updating --- evennia/commands/default/building.py | 123 +++++++++++++++++---------- evennia/utils/spawner.py | 25 ++++++ 2 files changed, 105 insertions(+), 43 deletions(-) diff --git a/evennia/commands/default/building.py b/evennia/commands/default/building.py index b41b5c40e9..4aabc861b1 100644 --- a/evennia/commands/default/building.py +++ b/evennia/commands/default/building.py @@ -13,10 +13,7 @@ from evennia.utils import create, utils, search from evennia.utils.utils import inherits_from, class_from_module, get_all_typeclasses from evennia.utils.eveditor import EvEditor from evennia.utils.evmore import EvMore -from evennia.utils.spawner import (spawn, search_prototype, list_prototypes, - save_db_prototype, validate_prototype, - delete_db_prototype, PermissionError, start_olc, - prototype_to_str) +from evennia.utils import spawner from evennia.utils.ansi import raw COMMAND_DEFAULT_CLASS = class_from_module(settings.COMMAND_DEFAULT_CLASS) @@ -2792,12 +2789,6 @@ class CmdTag(COMMAND_DEFAULT_CLASS): string = "No tags attached to %s." % obj self.caller.msg(string) -# -# To use the prototypes with the @spawn function set -# PROTOTYPE_MODULES = ["commands.prototypes"] -# Reload the server and the prototypes should be available. -# - class CmdSpawn(COMMAND_DEFAULT_CLASS): """ @@ -2810,6 +2801,7 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): @spawn/search [key][;tag[,tag]] @spawn/list [tag, tag] @spawn/show [] + @spawn/update @spawn/save [;desc[;tag,tag[,...][;lockstring]]] = @spawn/menu [] @@ -2823,6 +2815,10 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): show, examine - inspect prototype by key. If not given, acts like list. save - save a prototype to the database. It will be listable by /list. delete - remove a prototype from database, if allowed to. + update - find existing objects with the same prototype_key and update + them with latest version of given prototype. If given with /save, + will auto-update all objects with the old version of the prototype + without asking first. menu, olc - create/manipulate prototype in a menu interface. Example: @@ -2843,7 +2839,8 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): |waliases |n - string or list of strings. |wndb_|n - value of a nattribute (ndb_ is stripped) - |wprototype_key|n - name of this prototype. Used to store/retrieve from db + |wprototype_key|n - name of this prototype. Unique. Used to store/retrieve from db + and update existing prototyped objects if desired. |wprototype_desc|n - desc of this prototype. Used in listings |wprototype_locks|n - locks of this prototype. Limits who may use prototype |wprototype_tags|n - tags of this prototype. Used to find prototype @@ -2858,7 +2855,7 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): key = "@spawn" aliases = ["@olc"] - switch_options = ("noloc", "search", "list", "show", "save", "delete", "menu", "olc") + switch_options = ("noloc", "search", "list", "show", "save", "delete", "menu", "olc", "update") locks = "cmd:perm(spawn) or perm(Builder)" help_category = "Building" @@ -2890,7 +2887,7 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): "use the 'exec' prototype key.") return None try: - validate_prototype(prototype) + spawner.validate_prototype(prototype) except RuntimeError as err: self.caller.msg(str(err)) return @@ -2899,9 +2896,9 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): def _search_show_prototype(query, prototypes=None): # prototype detail if not prototypes: - prototypes = search_prototype(key=query) + prototypes = spawner.search_prototype(key=query) if prototypes: - return "\n".join(prototype_to_str(prot) for prot in prototypes) + return "\n".join(spawner.prototype_to_str(prot) for prot in prototypes) else: return False @@ -2912,7 +2909,7 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): prototype = None if self.lhs: key = self.lhs - prototype = search_prototype(key=key, return_meta=True) + prototype = spawner.search_prototype(key=key, return_meta=True) if len(prototype) > 1: caller.msg("More than one match for {}:\n{}".format( key, "\n".join(proto.get('prototype_key', '') for proto in prototype))) @@ -2920,7 +2917,7 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): elif prototype: # one match prototype = prototype[0] - start_olc(caller, session=self.session, prototype=prototype) + spawner.start_olc(caller, session=self.session, prototype=prototype) return if 'search' in self.switches: @@ -2932,7 +2929,7 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): if ';' in self.args: key, tags = (part.strip().lower() for part in self.args.split(";", 1)) tags = [tag.strip() for tag in tags.split(",")] if tags else None - EvMore(caller, unicode(list_prototypes(caller, key=key, tags=tags)), + EvMore(caller, unicode(spawner.list_prototypes(caller, key=key, tags=tags)), exit_on_lastpage=True) return @@ -2950,30 +2947,10 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): if 'list' in self.switches: # for list, all optional arguments are tags - EvMore(caller, unicode(list_prototypes(caller, + EvMore(caller, unicode(spawner.list_prototypes(caller, tags=self.lhslist)), exit_on_lastpage=True) return - if 'delete' in self.switches: - # remove db-based prototype - matchstring = _search_show_prototype(self.args) - if matchstring: - question = "\nDo you want to continue deleting? [Y]/N" - string = "|rDeleting prototype:|n\n{}".format(matchstring) - answer = yield(string + question) - if answer.lower() in ["n", "no"]: - caller.msg("|rDeletion cancelled.|n") - return - try: - success = delete_db_prototype(caller, self.args) - except PermissionError as err: - caller.msg("|rError deleting:|R {}|n".format(err)) - caller.msg("Deletion {}.".format( - 'successful' if success else 'failed (does the prototype exist?)')) - return - else: - caller.msg("Could not find prototype '{}'".format(key)) - if 'save' in self.switches: # store a prototype to the database store if not self.args or not self.rhs: @@ -3015,6 +2992,12 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): if not prototype: return + # inject the prototype_* keys into the prototype to save + prototype['prototype_key'] = prototype.get('prototype_key', key) + prototype['prototype_desc'] = prototype.get('prototype_desc', desc) + prototype['prototype_tags'] = prototype.get('prototype_tags', tags) + prototype['prototype_locks'] = prototype.get('prototype_locks', lockstring) + # present prototype to save new_matchstring = _search_show_prototype("", prototypes=[prototype]) string = "|yCreating new prototype:|n\n{}".format(new_matchstring) @@ -3034,7 +3017,7 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): # all seems ok. Try to save. try: - prot = save_db_prototype( + prot = spawner.save_db_prototype( caller, key, prototype, desc=desc, tags=tags, locks=lockstring) if not prot: caller.msg("|rError saving:|R {}.|n".format(key)) @@ -3046,14 +3029,68 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): caller.msg("|rError saving:|R {}|n".format(err)) return caller.msg("|gSaved prototype:|n {}".format(key)) + + # check if we want to update existing objects + existing_objects = spawner.search_objects_with_prototype(key) + if existing_objects: + if 'update' not in self.switches: + n_existing = len(existing_objects) + slow = " (note that this may be slow)" if n_existing > 10 else "" + string = ("There are {} objects already created with an older version " + "of prototype {}. Should it be re-applied to them{}? [Y]/N".format( + n_existing, key, slow)) + answer = yield(string) + if answer.lower() in ["n", "no"]: + caller.msg("|rNo update was done of existing objects. " + "Use @spawn/update to apply later as needed.|n") + return + n_updated = spawner.batch_update_objects_with_prototype(existing_objects, key) + caller.msg("{} objects were updated.".format(n_updated)) return if not self.args: - ncount = len(search_prototype()) + ncount = len(spawner.search_prototype()) caller.msg("Usage: @spawn or {{key: value, ...}}" "\n ({} existing prototypes. Use /list to inspect)".format(ncount)) return + if 'delete' in self.switches: + # remove db-based prototype + matchstring = _search_show_prototype(self.args) + if matchstring: + string = "|rDeleting prototype:|n\n{}".format(matchstring) + question = "\nDo you want to continue deleting? [Y]/N" + answer = yield(string + question) + if answer.lower() in ["n", "no"]: + caller.msg("|rDeletion cancelled.|n") + return + try: + success = spawner.delete_db_prototype(caller, self.args) + except PermissionError as err: + caller.msg("|rError deleting:|R {}|n".format(err)) + caller.msg("Deletion {}.".format( + 'successful' if success else 'failed (does the prototype exist?)')) + return + else: + caller.msg("Could not find prototype '{}'".format(key)) + + if 'update' in self.switches: + # update existing prototypes + key = self.args.strip().lower() + existing_objects = spawner.search_objects_with_prototype(key) + if existing_objects: + n_existing = len(existing_objects) + slow = " (note that this may be slow)" if n_existing > 10 else "" + string = ("There are {} objects already created with an older version " + "of prototype {}. Should it be re-applied to them{}? [Y]/N".format( + n_existing, key, slow)) + answer = yield(string) + if answer.lower() in ["n", "no"]: + caller.msg("|rUpdate cancelled.") + return + n_updated = spawner.batch_update_objects_with_prototype(existing_objects, key) + caller.msg("{} objects were updated.".format(n_updated)) + # A direct creation of an object from a given prototype prototype = _parse_prototype( @@ -3066,7 +3103,7 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): if isinstance(prototype, basestring): # A prototype key we are looking to apply key = prototype - prototypes = search_prototype(prototype) + prototypes = spawner.search_prototype(prototype) nprots = len(prototypes) if not prototypes: caller.msg("No prototype named '%s'." % prototype) @@ -3087,7 +3124,7 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): # proceed to spawning try: - for obj in spawn(prototype): + for obj in spawner.spawn(prototype): self.caller.msg("Spawned %s." % obj.get_display_name(self.caller)) except RuntimeError as err: caller.msg(err) diff --git a/evennia/utils/spawner.py b/evennia/utils/spawner.py index aa62a1dcad..06cb59c178 100644 --- a/evennia/utils/spawner.py +++ b/evennia/utils/spawner.py @@ -270,6 +270,9 @@ class DbPrototype(DefaultScript): self.desc = "A prototype" # prototype_desc + + + def save_db_prototype(caller, prototype, key=None, desc=None, tags=None, locks="", delete=False): """ Store a prototype persistently. @@ -662,6 +665,28 @@ def _get_prototype(dic, prot, protparents): return prot +def batch_update_objects_with_prototype(prototype, objects=None): + """ + Update existing objects with the latest version of the prototype. + + Args: + prototype (str or dict): Either the `prototype_key` to use or the + prototype dict itself. + objects (list): List of objects to update. If not given, query for these + objects using the prototype's `prototype_key`. + Returns: + changed (int): The number of objects that had changes applied to them. + """ + prototype_key = prototype if isinstance(prototype, basestring) else prototype['prototype_key'] + prototype_obj = search_db_prototype(prototype_key, return_queryset=True) + prototype_obj = prototype_obj[0] if prototype_obj else None + new_prototype = prototype_obj.db.prototype + + + + return 0 + + def _batch_create_object(*objparams): """ This is a cut-down version of the create_object() function, From 812d256225162a9aef1ae97641457d4a5a6988dd Mon Sep 17 00:00:00 2001 From: BattleJenkins Date: Mon, 21 May 2018 18:06:29 -0700 Subject: [PATCH 125/208] Proper formatting for comments and notes --- evennia/contrib/turnbattle/tb_magic.py | 33 ++++++++++++-------------- 1 file changed, 15 insertions(+), 18 deletions(-) diff --git a/evennia/contrib/turnbattle/tb_magic.py b/evennia/contrib/turnbattle/tb_magic.py index 01101837b3..7bf87d70be 100644 --- a/evennia/contrib/turnbattle/tb_magic.py +++ b/evennia/contrib/turnbattle/tb_magic.py @@ -326,19 +326,19 @@ class TBMagicCharacter(DefaultCharacter): """ Called once, when this object is first created. This is the normal hook to overload for most object types. - """ - self.db.max_hp = 100 # Set maximum HP to 100 - self.db.hp = self.db.max_hp # Set current HP to maximum - self.db.spells_known = [] # Set empty spells known list - self.db.max_mp = 20 # Set maximum MP to 20 - self.db.mp = self.db.max_mp # Set current MP to maximum - """ + Adds attributes for a character's current and maximum HP. We're just going to set this value at '100' by default. You may want to expand this to include various 'stats' that can be changed at creation and factor into combat calculations. """ + self.db.max_hp = 100 # Set maximum HP to 100 + self.db.hp = self.db.max_hp # Set current HP to maximum + self.db.spells_known = [] # Set empty spells known list + self.db.max_mp = 20 # Set maximum MP to 20 + self.db.mp = self.db.max_mp # Set current MP to maximum + def at_before_move(self, destination): """ @@ -795,6 +795,13 @@ class CmdCast(MuxCommand): def func(self): """ This performs the actual command. + + Note: This is a quite long command, since it has to cope with all + the different circumstances in which you may or may not be able + to cast a spell. None of the spell's effects are handled by the + command - all the command does is verify that the player's input + is valid for the spell being cast and then call the spell's + function. """ caller = self.caller @@ -945,14 +952,6 @@ class CmdCast(MuxCommand): spelldata["spellfunc"](caller, spell_to_cast, spell_targets, spelldata["cost"], **kwargs) except Exception: log_trace("Error in callback for spell: %s." % spell_to_cast) - """ - Note: This is a quite long command, since it has to cope with all - the different circumstances in which you may or may not be able - to cast a spell. None of the spell's effects are handled by the - command - all the command does is verify that the player's input - is valid for the spell being cast and then call the spell's - function. - """ class CmdRest(Command): @@ -979,9 +978,7 @@ class CmdRest(Command): self.caller.db.hp = self.caller.db.max_hp # Set current HP to maximum self.caller.db.mp = self.caller.db.max_mp # Set current MP to maximum self.caller.location.msg_contents("%s rests to recover HP and MP." % self.caller) - """ - You'll probably want to replace this with your own system for recovering HP and MP. - """ + # You'll probably want to replace this with your own system for recovering HP and MP. class CmdStatus(Command): """ From f3031fee31d6be10a8215055b374955353cf3cbf Mon Sep 17 00:00:00 2001 From: BattleJenkins Date: Mon, 21 May 2018 18:27:04 -0700 Subject: [PATCH 126/208] Start splitting unit tests, add setUp/tearDown --- evennia/contrib/tests.py | 131 +++++++++++++++++++++++---------------- 1 file changed, 76 insertions(+), 55 deletions(-) diff --git a/evennia/contrib/tests.py b/evennia/contrib/tests.py index 5203354fff..2de8f2156e 100644 --- a/evennia/contrib/tests.py +++ b/evennia/contrib/tests.py @@ -987,87 +987,101 @@ class TestTurnBattleCmd(CommandTest): self.call(tb_magic.CmdRest(), "", "Char rests to recover HP and MP.") -class TestTurnBattleFunc(EvenniaTest): +class TestTurnBattleBasicFunc(EvenniaTest): + def setUp(self): + super(TestTurnBattleBasicFunc, self).setUp() + self.attacker = create_object(tb_basic.TBBasicCharacter, key="Attacker") + self.defender = create_object(tb_basic.TBBasicCharacter, key="Defender") + self.joiner = create_object(tb_basic.TBBasicCharacter, key="Joiner") + self.testroom = create_object(DefaultRoom, key="Test Room") + self.attacker.location = self.testroom + self.defender.loaction = self.testroom + self.joiner.loaction = None + + def tearDown(self): + super(TestTurnBattleBasicFunc, self).tearDown() + self.attacker.delete() + self.defender.delete() + self.joiner.delete() + self.testroom.delete() + self.turnhandler.stop() + # Test combat functions def test_tbbasicfunc(self): - attacker = create_object(tb_basic.TBBasicCharacter, key="Attacker") - defender = create_object(tb_basic.TBBasicCharacter, key="Defender") - testroom = create_object(DefaultRoom, key="Test Room") - attacker.location = testroom - defender.loaction = testroom # Initiative roll - initiative = tb_basic.roll_init(attacker) + initiative = tb_basic.roll_init(self.attacker) self.assertTrue(initiative >= 0 and initiative <= 1000) # Attack roll - attack_roll = tb_basic.get_attack(attacker, defender) + attack_roll = tb_basic.get_attack(self.attacker, self.defender) self.assertTrue(attack_roll >= 0 and attack_roll <= 100) # Defense roll - defense_roll = tb_basic.get_defense(attacker, defender) + defense_roll = tb_basic.get_defense(self.attacker, self.defender) self.assertTrue(defense_roll == 50) # Damage roll - damage_roll = tb_basic.get_damage(attacker, defender) + damage_roll = tb_basic.get_damage(self.attacker, self.defender) self.assertTrue(damage_roll >= 15 and damage_roll <= 25) # Apply damage - defender.db.hp = 10 - tb_basic.apply_damage(defender, 3) - self.assertTrue(defender.db.hp == 7) + self.defender.db.hp = 10 + tb_basic.apply_damage(self.defender, 3) + self.assertTrue(self.defender.db.hp == 7) # Resolve attack - defender.db.hp = 40 - tb_basic.resolve_attack(attacker, defender, attack_value=20, defense_value=10) - self.assertTrue(defender.db.hp < 40) + self.defender.db.hp = 40 + tb_basic.resolve_attack(self.attacker, self.defender, attack_value=20, defense_value=10) + self.assertTrue(self.defender.db.hp < 40) # Combat cleanup - attacker.db.Combat_attribute = True - tb_basic.combat_cleanup(attacker) - self.assertFalse(attacker.db.combat_attribute) + self.attacker.db.Combat_attribute = True + tb_basic.combat_cleanup(self.attacker) + self.assertFalse(self.attacker.db.combat_attribute) # Is in combat - self.assertFalse(tb_basic.is_in_combat(attacker)) + self.assertFalse(tb_basic.is_in_combat(self.attacker)) # Set up turn handler script for further tests - attacker.location.scripts.add(tb_basic.TBBasicTurnHandler) - turnhandler = attacker.db.combat_TurnHandler - self.assertTrue(attacker.db.combat_TurnHandler) + self.attacker.location.scripts.add(tb_basic.TBBasicTurnHandler) + self.turnhandler = self.attacker.db.combat_TurnHandler + self.assertTrue(self.attacker.db.combat_TurnHandler) # Set the turn handler's interval very high to keep it from repeating during tests. - turnhandler.interval = 10000 + self.turnhandler.interval = 10000 # Force turn order - turnhandler.db.fighters = [attacker, defender] - turnhandler.db.turn = 0 + self.turnhandler.db.fighters = [self.attacker, self.defender] + self.turnhandler.db.turn = 0 # Test is turn - self.assertTrue(tb_basic.is_turn(attacker)) + self.assertTrue(tb_basic.is_turn(self.attacker)) # Spend actions - attacker.db.Combat_ActionsLeft = 1 - tb_basic.spend_action(attacker, 1, action_name="Test") - self.assertTrue(attacker.db.Combat_ActionsLeft == 0) - self.assertTrue(attacker.db.Combat_LastAction == "Test") + self.attacker.db.Combat_ActionsLeft = 1 + tb_basic.spend_action(self.attacker, 1, action_name="Test") + self.assertTrue(self.attacker.db.Combat_ActionsLeft == 0) + self.assertTrue(self.attacker.db.Combat_LastAction == "Test") # Initialize for combat - attacker.db.Combat_ActionsLeft = 983 - turnhandler.initialize_for_combat(attacker) - self.assertTrue(attacker.db.Combat_ActionsLeft == 0) - self.assertTrue(attacker.db.Combat_LastAction == "null") + self.attacker.db.Combat_ActionsLeft = 983 + self.turnhandler.initialize_for_combat(self.attacker) + self.assertTrue(self.attacker.db.Combat_ActionsLeft == 0) + self.assertTrue(self.attacker.db.Combat_LastAction == "null") # Start turn - defender.db.Combat_ActionsLeft = 0 - turnhandler.start_turn(defender) - self.assertTrue(defender.db.Combat_ActionsLeft == 1) + self.defender.db.Combat_ActionsLeft = 0 + self.turnhandler.start_turn(self.defender) + self.assertTrue(self.defender.db.Combat_ActionsLeft == 1) # Next turn - turnhandler.db.fighters = [attacker, defender] - turnhandler.db.turn = 0 - turnhandler.next_turn() - self.assertTrue(turnhandler.db.turn == 1) + self.turnhandler.db.fighters = [self.attacker, self.defender] + self.turnhandler.db.turn = 0 + self.turnhandler.next_turn() + self.assertTrue(self.turnhandler.db.turn == 1) # Turn end check - turnhandler.db.fighters = [attacker, defender] - turnhandler.db.turn = 0 - attacker.db.Combat_ActionsLeft = 0 - turnhandler.turn_end_check(attacker) - self.assertTrue(turnhandler.db.turn == 1) + self.turnhandler.db.fighters = [self.attacker, self.defender] + self.turnhandler.db.turn = 0 + self.attacker.db.Combat_ActionsLeft = 0 + self.turnhandler.turn_end_check(self.attacker) + self.assertTrue(self.turnhandler.db.turn == 1) # Join fight - joiner = create_object(tb_basic.TBBasicCharacter, key="Joiner") - turnhandler.db.fighters = [attacker, defender] - turnhandler.db.turn = 0 - turnhandler.join_fight(joiner) - self.assertTrue(turnhandler.db.turn == 1) - self.assertTrue(turnhandler.db.fighters == [joiner, attacker, defender]) - # Remove the script at the end - turnhandler.stop() + self.joiner.location = self.testroom + self.turnhandler.db.fighters = [self.attacker, self.defender] + self.turnhandler.db.turn = 0 + self.turnhandler.join_fight(self.joiner) + self.assertTrue(self.turnhandler.db.turn == 1) + self.assertTrue(self.turnhandler.db.fighters == [self.joiner, self.attacker, self.defender]) + +class TestTurnBattleEquipFunc(EvenniaTest): + # Test the combat functions in tb_equip too. They work mostly the same. def test_tbequipfunc(self): attacker = create_object(tb_equip.TBEquipCharacter, key="Attacker") @@ -1147,6 +1161,8 @@ class TestTurnBattleFunc(EvenniaTest): # Remove the script at the end turnhandler.stop() +class TestTurnBattleRangeFunc(EvenniaTest): + # Test combat functions in tb_range too. def test_tbrangefunc(self): testroom = create_object(DefaultRoom, key="Test Room") @@ -1239,7 +1255,10 @@ class TestTurnBattleFunc(EvenniaTest): # Remove the script at the end turnhandler.stop() +class TestTurnBattleItemsFunc(EvenniaTest): + # Test functions in tb_items. + def test_tbitemsfunc(self): attacker = create_object(tb_items.TBItemsCharacterTest, key="Attacker") defender = create_object(tb_items.TBItemsCharacterTest, key="Defender") @@ -1352,6 +1371,8 @@ class TestTurnBattleFunc(EvenniaTest): # Delete the test character user.delete() +class TestTurnBattleMagicFunc(EvenniaTest): + # Test combat functions in tb_magic. def test_tbbasicfunc(self): attacker = create_object(tb_magic.TBMagicCharacter, key="Attacker") From f7ae91238c21cfcab0b8525b57ef3530a9a9e9b9 Mon Sep 17 00:00:00 2001 From: BattleJenkins Date: Mon, 21 May 2018 18:45:07 -0700 Subject: [PATCH 127/208] More setUp/tearDown --- evennia/contrib/tests.py | 118 +++++++++++++++++++++------------------ 1 file changed, 64 insertions(+), 54 deletions(-) diff --git a/evennia/contrib/tests.py b/evennia/contrib/tests.py index 2de8f2156e..721fd43801 100644 --- a/evennia/contrib/tests.py +++ b/evennia/contrib/tests.py @@ -1081,85 +1081,95 @@ class TestTurnBattleBasicFunc(EvenniaTest): class TestTurnBattleEquipFunc(EvenniaTest): + + def setUp(self): + super(TestTurnBattleEquipFunc, self).setUp() + self.attacker = create_object(tb_equip.TBEquipCharacter, key="Attacker") + self.defender = create_object(tb_equip.TBEquipCharacter, key="Defender") + self.testroom = create_object(DefaultRoom, key="Test Room") + self.joiner = create_object(tb_equip.TBEquipCharacter, key="Joiner") + self.attacker.location = self.testroom + self.defender.loaction = self.testroom + self.joiner.loaction = None + + def tearDown(self): + super(TestTurnBattleEquipFunc, self).tearDown() + self.attacker.delete() + self.defender.delete() + self.joiner.delete() + self.testroom.delete() + self.turnhandler.stop() # Test the combat functions in tb_equip too. They work mostly the same. def test_tbequipfunc(self): - attacker = create_object(tb_equip.TBEquipCharacter, key="Attacker") - defender = create_object(tb_equip.TBEquipCharacter, key="Defender") - testroom = create_object(DefaultRoom, key="Test Room") - attacker.location = testroom - defender.loaction = testroom # Initiative roll - initiative = tb_equip.roll_init(attacker) + initiative = tb_equip.roll_init(self.attacker) self.assertTrue(initiative >= 0 and initiative <= 1000) # Attack roll - attack_roll = tb_equip.get_attack(attacker, defender) + attack_roll = tb_equip.get_attack(self.attacker, self.defender) self.assertTrue(attack_roll >= -50 and attack_roll <= 150) # Defense roll - defense_roll = tb_equip.get_defense(attacker, defender) + defense_roll = tb_equip.get_defense(self.attacker, self.defender) self.assertTrue(defense_roll == 50) # Damage roll - damage_roll = tb_equip.get_damage(attacker, defender) + damage_roll = tb_equip.get_damage(self.attacker, self.defender) self.assertTrue(damage_roll >= 0 and damage_roll <= 50) # Apply damage - defender.db.hp = 10 - tb_equip.apply_damage(defender, 3) - self.assertTrue(defender.db.hp == 7) + self.defender.db.hp = 10 + tb_equip.apply_damage(self.defender, 3) + self.assertTrue(self.defender.db.hp == 7) # Resolve attack - defender.db.hp = 40 - tb_equip.resolve_attack(attacker, defender, attack_value=20, defense_value=10) - self.assertTrue(defender.db.hp < 40) + self.defender.db.hp = 40 + tb_equip.resolve_attack(self.attacker, self.defender, attack_value=20, defense_value=10) + self.assertTrue(self.defender.db.hp < 40) # Combat cleanup - attacker.db.Combat_attribute = True - tb_equip.combat_cleanup(attacker) - self.assertFalse(attacker.db.combat_attribute) + self.attacker.db.Combat_attribute = True + tb_equip.combat_cleanup(self.attacker) + self.assertFalse(self.attacker.db.combat_attribute) # Is in combat - self.assertFalse(tb_equip.is_in_combat(attacker)) + self.assertFalse(tb_equip.is_in_combat(self.attacker)) # Set up turn handler script for further tests - attacker.location.scripts.add(tb_equip.TBEquipTurnHandler) - turnhandler = attacker.db.combat_TurnHandler - self.assertTrue(attacker.db.combat_TurnHandler) + self.attacker.location.scripts.add(tb_equip.TBEquipTurnHandler) + self.turnhandler = self.attacker.db.combat_TurnHandler + self.assertTrue(self.attacker.db.combat_TurnHandler) # Set the turn handler's interval very high to keep it from repeating during tests. - turnhandler.interval = 10000 + self.turnhandler.interval = 10000 # Force turn order - turnhandler.db.fighters = [attacker, defender] - turnhandler.db.turn = 0 + self.turnhandler.db.fighters = [self.attacker, self.defender] + self.turnhandler.db.turn = 0 # Test is turn - self.assertTrue(tb_equip.is_turn(attacker)) + self.assertTrue(tb_equip.is_turn(self.attacker)) # Spend actions - attacker.db.Combat_ActionsLeft = 1 - tb_equip.spend_action(attacker, 1, action_name="Test") - self.assertTrue(attacker.db.Combat_ActionsLeft == 0) - self.assertTrue(attacker.db.Combat_LastAction == "Test") + self.attacker.db.Combat_ActionsLeft = 1 + tb_equip.spend_action(self.attacker, 1, action_name="Test") + self.assertTrue(self.attacker.db.Combat_ActionsLeft == 0) + self.assertTrue(self.attacker.db.Combat_LastAction == "Test") # Initialize for combat - attacker.db.Combat_ActionsLeft = 983 - turnhandler.initialize_for_combat(attacker) - self.assertTrue(attacker.db.Combat_ActionsLeft == 0) - self.assertTrue(attacker.db.Combat_LastAction == "null") + self.attacker.db.Combat_ActionsLeft = 983 + self.turnhandler.initialize_for_combat(self.attacker) + self.assertTrue(self.attacker.db.Combat_ActionsLeft == 0) + self.assertTrue(self.attacker.db.Combat_LastAction == "null") # Start turn - defender.db.Combat_ActionsLeft = 0 - turnhandler.start_turn(defender) - self.assertTrue(defender.db.Combat_ActionsLeft == 1) + self.defender.db.Combat_ActionsLeft = 0 + self.turnhandler.start_turn(self.defender) + self.assertTrue(self.defender.db.Combat_ActionsLeft == 1) # Next turn - turnhandler.db.fighters = [attacker, defender] - turnhandler.db.turn = 0 - turnhandler.next_turn() - self.assertTrue(turnhandler.db.turn == 1) + self.turnhandler.db.fighters = [self.attacker, self.defender] + self.turnhandler.db.turn = 0 + self.turnhandler.next_turn() + self.assertTrue(self.turnhandler.db.turn == 1) # Turn end check - turnhandler.db.fighters = [attacker, defender] - turnhandler.db.turn = 0 - attacker.db.Combat_ActionsLeft = 0 - turnhandler.turn_end_check(attacker) - self.assertTrue(turnhandler.db.turn == 1) + self.turnhandler.db.fighters = [self.attacker, self.defender] + self.turnhandler.db.turn = 0 + self.attacker.db.Combat_ActionsLeft = 0 + self.turnhandler.turn_end_check(self.attacker) + self.assertTrue(self.turnhandler.db.turn == 1) # Join fight - joiner = create_object(tb_equip.TBEquipCharacter, key="Joiner") - turnhandler.db.fighters = [attacker, defender] - turnhandler.db.turn = 0 - turnhandler.join_fight(joiner) - self.assertTrue(turnhandler.db.turn == 1) - self.assertTrue(turnhandler.db.fighters == [joiner, attacker, defender]) - # Remove the script at the end - turnhandler.stop() + self.turnhandler.db.fighters = [self.attacker, self.defender] + self.turnhandler.db.turn = 0 + self.turnhandler.join_fight(self.joiner) + self.assertTrue(self.turnhandler.db.turn == 1) + self.assertTrue(self.turnhandler.db.fighters == [self.joiner, self.attacker, self.defender]) class TestTurnBattleRangeFunc(EvenniaTest): From d550f3e476bb2da96a361a2115e133ddd9661cae Mon Sep 17 00:00:00 2001 From: BattleJenkins Date: Mon, 21 May 2018 19:27:43 -0700 Subject: [PATCH 128/208] Finish splitting TB test classes + adding setUp/tearDown --- evennia/contrib/tests.py | 465 +++++++++++++++++++++------------------ 1 file changed, 246 insertions(+), 219 deletions(-) diff --git a/evennia/contrib/tests.py b/evennia/contrib/tests.py index 721fd43801..c0721b3ed6 100644 --- a/evennia/contrib/tests.py +++ b/evennia/contrib/tests.py @@ -920,23 +920,28 @@ from evennia.contrib.turnbattle import tb_basic, tb_equip, tb_range, tb_items, t from evennia.objects.objects import DefaultRoom -class TestTurnBattleCmd(CommandTest): +class TestTurnBattleBasicCmd(CommandTest): - # Test combat commands + # Test basic combat commands def test_turnbattlecmd(self): self.call(tb_basic.CmdFight(), "", "You can't start a fight if you've been defeated!") self.call(tb_basic.CmdAttack(), "", "You can only do that in combat. (see: help fight)") self.call(tb_basic.CmdPass(), "", "You can only do that in combat. (see: help fight)") self.call(tb_basic.CmdDisengage(), "", "You can only do that in combat. (see: help fight)") self.call(tb_basic.CmdRest(), "", "Char rests to recover HP.") - + +class TestTurnBattleEquipCmd(CommandTest): + + def setUp(self): + super(TestTurnBattleEquipCmd, self).setUp() + self.testweapon = create_object(tb_equip.TBEWeapon, key="test weapon") + self.testarmor = create_object(tb_equip.TBEArmor, key="test armor") + self.testweapon.move_to(self.char1) + self.testarmor.move_to(self.char1) + # Test equipment commands def test_turnbattleequipcmd(self): # Start with equip module specific commands. - testweapon = create_object(tb_equip.TBEWeapon, key="test weapon") - testarmor = create_object(tb_equip.TBEArmor, key="test armor") - testweapon.move_to(self.char1) - testarmor.move_to(self.char1) self.call(tb_equip.CmdWield(), "weapon", "Char wields test weapon.") self.call(tb_equip.CmdUnwield(), "", "Char lowers test weapon.") self.call(tb_equip.CmdDon(), "armor", "Char dons test armor.") @@ -947,6 +952,8 @@ class TestTurnBattleCmd(CommandTest): self.call(tb_equip.CmdPass(), "", "You can only do that in combat. (see: help fight)") self.call(tb_equip.CmdDisengage(), "", "You can only do that in combat. (see: help fight)") self.call(tb_equip.CmdRest(), "", "Char rests to recover HP.") + +class TestTurnBattleRangeCmd(CommandTest): # Test range commands def test_turnbattlerangecmd(self): @@ -961,11 +968,16 @@ class TestTurnBattleCmd(CommandTest): self.call(tb_range.CmdPass(), "", "You can only do that in combat. (see: help fight)") self.call(tb_range.CmdDisengage(), "", "You can only do that in combat. (see: help fight)") self.call(tb_range.CmdRest(), "", "Char rests to recover HP.") - + +class TestTurnBattleItemsCmd(CommandTest): + + def setUp(self): + super(TestTurnBattleItemsCmd, self).setUp() + self.testitem = create_object(key="test item") + self.testitem.move_to(self.char1) + # Test item commands def test_turnbattleitemcmd(self): - testitem = create_object(key="test item") - testitem.move_to(self.char1) self.call(tb_items.CmdUse(), "item", "'Test item' is not a usable item.") # Also test the commands that are the same in the basic module self.call(tb_items.CmdFight(), "", "You can't start a fight if you've been defeated!") @@ -974,6 +986,8 @@ class TestTurnBattleCmd(CommandTest): self.call(tb_items.CmdDisengage(), "", "You can only do that in combat. (see: help fight)") self.call(tb_items.CmdRest(), "", "Char rests to recover HP.") +class TestTurnBattleMagicCmd(CommandTest): + # Test magic commands def test_turnbattlemagiccmd(self): self.call(tb_magic.CmdStatus(), "", "You have 100 / 100 HP and 20 / 20 MP.") @@ -991,13 +1005,10 @@ class TestTurnBattleBasicFunc(EvenniaTest): def setUp(self): super(TestTurnBattleBasicFunc, self).setUp() - self.attacker = create_object(tb_basic.TBBasicCharacter, key="Attacker") - self.defender = create_object(tb_basic.TBBasicCharacter, key="Defender") - self.joiner = create_object(tb_basic.TBBasicCharacter, key="Joiner") self.testroom = create_object(DefaultRoom, key="Test Room") - self.attacker.location = self.testroom - self.defender.loaction = self.testroom - self.joiner.loaction = None + self.attacker = create_object(tb_basic.TBBasicCharacter, key="Attacker", location=self.testroom) + self.defender = create_object(tb_basic.TBBasicCharacter, key="Defender", location=self.testroom) + self.joiner = create_object(tb_basic.TBBasicCharacter, key="Joiner", location=None) def tearDown(self): super(TestTurnBattleBasicFunc, self).tearDown() @@ -1084,13 +1095,10 @@ class TestTurnBattleEquipFunc(EvenniaTest): def setUp(self): super(TestTurnBattleEquipFunc, self).setUp() - self.attacker = create_object(tb_equip.TBEquipCharacter, key="Attacker") - self.defender = create_object(tb_equip.TBEquipCharacter, key="Defender") self.testroom = create_object(DefaultRoom, key="Test Room") - self.joiner = create_object(tb_equip.TBEquipCharacter, key="Joiner") - self.attacker.location = self.testroom - self.defender.loaction = self.testroom - self.joiner.loaction = None + self.attacker = create_object(tb_equip.TBEquipCharacter, key="Attacker", location=self.testroom) + self.defender = create_object(tb_equip.TBEquipCharacter, key="Defender", location=self.testroom) + self.joiner = create_object(tb_equip.TBEquipCharacter, key="Joiner", location=None) def tearDown(self): super(TestTurnBattleEquipFunc, self).tearDown() @@ -1173,294 +1181,313 @@ class TestTurnBattleEquipFunc(EvenniaTest): class TestTurnBattleRangeFunc(EvenniaTest): + def setUp(self): + super(TestTurnBattleRangeFunc, self).setUp() + self.testroom = create_object(DefaultRoom, key="Test Room") + self.attacker = create_object(tb_range.TBRangeCharacter, key="Attacker", location=self.testroom) + self.defender = create_object(tb_range.TBRangeCharacter, key="Defender", location=self.testroom) + self.joiner = create_object(tb_range.TBRangeCharacter, key="Joiner", location=self.testroom) + + def tearDown(self): + super(TestTurnBattleRangeFunc, self).tearDown() + self.attacker.delete() + self.defender.delete() + self.joiner.delete() + self.testroom.delete() + self.turnhandler.stop() + # Test combat functions in tb_range too. def test_tbrangefunc(self): - testroom = create_object(DefaultRoom, key="Test Room") - attacker = create_object(tb_range.TBRangeCharacter, key="Attacker", location=testroom) - defender = create_object(tb_range.TBRangeCharacter, key="Defender", location=testroom) # Initiative roll - initiative = tb_range.roll_init(attacker) + initiative = tb_range.roll_init(self.attacker) self.assertTrue(initiative >= 0 and initiative <= 1000) # Attack roll - attack_roll = tb_range.get_attack(attacker, defender, "test") + attack_roll = tb_range.get_attack(self.attacker, self.defender, "test") self.assertTrue(attack_roll >= 0 and attack_roll <= 100) # Defense roll - defense_roll = tb_range.get_defense(attacker, defender, "test") + defense_roll = tb_range.get_defense(self.attacker, self.defender, "test") self.assertTrue(defense_roll == 50) # Damage roll - damage_roll = tb_range.get_damage(attacker, defender) + damage_roll = tb_range.get_damage(self.attacker, self.defender) self.assertTrue(damage_roll >= 15 and damage_roll <= 25) # Apply damage - defender.db.hp = 10 - tb_range.apply_damage(defender, 3) - self.assertTrue(defender.db.hp == 7) + self.defender.db.hp = 10 + tb_range.apply_damage(self.defender, 3) + self.assertTrue(self.defender.db.hp == 7) # Resolve attack - defender.db.hp = 40 - tb_range.resolve_attack(attacker, defender, "test", attack_value=20, defense_value=10) - self.assertTrue(defender.db.hp < 40) + self.defender.db.hp = 40 + tb_range.resolve_attack(self.attacker, self.defender, "test", attack_value=20, defense_value=10) + self.assertTrue(self.defender.db.hp < 40) # Combat cleanup - attacker.db.Combat_attribute = True - tb_range.combat_cleanup(attacker) - self.assertFalse(attacker.db.combat_attribute) + self.attacker.db.Combat_attribute = True + tb_range.combat_cleanup(self.attacker) + self.assertFalse(self.attacker.db.combat_attribute) # Is in combat - self.assertFalse(tb_range.is_in_combat(attacker)) + self.assertFalse(tb_range.is_in_combat(self.attacker)) # Set up turn handler script for further tests - attacker.location.scripts.add(tb_range.TBRangeTurnHandler) - turnhandler = attacker.db.combat_TurnHandler - self.assertTrue(attacker.db.combat_TurnHandler) + self.attacker.location.scripts.add(tb_range.TBRangeTurnHandler) + self.turnhandler = self.attacker.db.combat_TurnHandler + self.assertTrue(self.attacker.db.combat_TurnHandler) # Set the turn handler's interval very high to keep it from repeating during tests. - turnhandler.interval = 10000 + self.turnhandler.interval = 10000 # Force turn order - turnhandler.db.fighters = [attacker, defender] - turnhandler.db.turn = 0 + self.turnhandler.db.fighters = [self.attacker, self.defender] + self.turnhandler.db.turn = 0 # Test is turn - self.assertTrue(tb_range.is_turn(attacker)) + self.assertTrue(tb_range.is_turn(self.attacker)) # Spend actions - attacker.db.Combat_ActionsLeft = 1 - tb_range.spend_action(attacker, 1, action_name="Test") - self.assertTrue(attacker.db.Combat_ActionsLeft == 0) - self.assertTrue(attacker.db.Combat_LastAction == "Test") + self.attacker.db.Combat_ActionsLeft = 1 + tb_range.spend_action(self.attacker, 1, action_name="Test") + self.assertTrue(self.attacker.db.Combat_ActionsLeft == 0) + self.assertTrue(self.attacker.db.Combat_LastAction == "Test") # Initialize for combat - attacker.db.Combat_ActionsLeft = 983 - turnhandler.initialize_for_combat(attacker) - self.assertTrue(attacker.db.Combat_ActionsLeft == 0) - self.assertTrue(attacker.db.Combat_LastAction == "null") + self.attacker.db.Combat_ActionsLeft = 983 + self.turnhandler.initialize_for_combat(self.attacker) + self.assertTrue(self.attacker.db.Combat_ActionsLeft == 0) + self.assertTrue(self.attacker.db.Combat_LastAction == "null") # Set up ranges again, since initialize_for_combat clears them - attacker.db.combat_range = {} - attacker.db.combat_range[attacker] = 0 - attacker.db.combat_range[defender] = 1 - defender.db.combat_range = {} - defender.db.combat_range[defender] = 0 - defender.db.combat_range[attacker] = 1 + self.attacker.db.combat_range = {} + self.attacker.db.combat_range[self.attacker] = 0 + self.attacker.db.combat_range[self.defender] = 1 + self.defender.db.combat_range = {} + self.defender.db.combat_range[self.defender] = 0 + self.defender.db.combat_range[self.attacker] = 1 # Start turn - defender.db.Combat_ActionsLeft = 0 - turnhandler.start_turn(defender) - self.assertTrue(defender.db.Combat_ActionsLeft == 2) + self.defender.db.Combat_ActionsLeft = 0 + self.turnhandler.start_turn(self.defender) + self.assertTrue(self.defender.db.Combat_ActionsLeft == 2) # Next turn - turnhandler.db.fighters = [attacker, defender] - turnhandler.db.turn = 0 - turnhandler.next_turn() - self.assertTrue(turnhandler.db.turn == 1) + self.turnhandler.db.fighters = [self.attacker, self.defender] + self.turnhandler.db.turn = 0 + self.turnhandler.next_turn() + self.assertTrue(self.turnhandler.db.turn == 1) # Turn end check - turnhandler.db.fighters = [attacker, defender] - turnhandler.db.turn = 0 - attacker.db.Combat_ActionsLeft = 0 - turnhandler.turn_end_check(attacker) - self.assertTrue(turnhandler.db.turn == 1) + self.turnhandler.db.fighters = [self.attacker, self.defender] + self.turnhandler.db.turn = 0 + self.attacker.db.Combat_ActionsLeft = 0 + self.turnhandler.turn_end_check(self.attacker) + self.assertTrue(self.turnhandler.db.turn == 1) # Join fight - joiner = create_object(tb_range.TBRangeCharacter, key="Joiner", location=testroom) - turnhandler.db.fighters = [attacker, defender] - turnhandler.db.turn = 0 - turnhandler.join_fight(joiner) - self.assertTrue(turnhandler.db.turn == 1) - self.assertTrue(turnhandler.db.fighters == [joiner, attacker, defender]) + self.turnhandler.db.fighters = [self.attacker, self.defender] + self.turnhandler.db.turn = 0 + self.turnhandler.join_fight(self.joiner) + self.assertTrue(self.turnhandler.db.turn == 1) + self.assertTrue(self.turnhandler.db.fighters == [self.joiner, self.attacker, self.defender]) # Now, test for approach/withdraw functions - self.assertTrue(tb_range.get_range(attacker, defender) == 1) + self.assertTrue(tb_range.get_range(self.attacker, self.defender) == 1) # Approach - tb_range.approach(attacker, defender) - self.assertTrue(tb_range.get_range(attacker, defender) == 0) + tb_range.approach(self.attacker, self.defender) + self.assertTrue(tb_range.get_range(self.attacker, self.defender) == 0) # Withdraw - tb_range.withdraw(attacker, defender) - self.assertTrue(tb_range.get_range(attacker, defender) == 1) - # Remove the script at the end - turnhandler.stop() + tb_range.withdraw(self.attacker, self.defender) + self.assertTrue(tb_range.get_range(self.attacker, self.defender) == 1) class TestTurnBattleItemsFunc(EvenniaTest): + def setUp(self): + super(TestTurnBattleItemsFunc, self).setUp() + self.testroom = create_object(DefaultRoom, key="Test Room") + self.attacker = create_object(tb_items.TBItemsCharacter, key="Attacker", location=self.testroom) + self.defender = create_object(tb_items.TBItemsCharacter, key="Defender", location=self.testroom) + self.joiner = create_object(tb_items.TBItemsCharacter, key="Joiner", location=self.testroom) + self.user = create_object(tb_items.TBItemsCharacter, key="User", location=self.testroom) + self.test_healpotion = create_object(key="healing potion") + self.test_healpotion.db.item_func = "heal" + self.test_healpotion.db.item_uses = 3 + + def tearDown(self): + super(TestTurnBattleItemsFunc, self).tearDown() + self.attacker.delete() + self.defender.delete() + self.joiner.delete() + self.user.delete() + self.testroom.delete() + self.turnhandler.stop() + # Test functions in tb_items. - def test_tbitemsfunc(self): - attacker = create_object(tb_items.TBItemsCharacterTest, key="Attacker") - defender = create_object(tb_items.TBItemsCharacterTest, key="Defender") - testroom = create_object(DefaultRoom, key="Test Room") - attacker.location = testroom - defender.loaction = testroom # Initiative roll - initiative = tb_items.roll_init(attacker) + initiative = tb_items.roll_init(self.attacker) self.assertTrue(initiative >= 0 and initiative <= 1000) # Attack roll - attack_roll = tb_items.get_attack(attacker, defender) + attack_roll = tb_items.get_attack(self.attacker, self.defender) self.assertTrue(attack_roll >= 0 and attack_roll <= 100) # Defense roll - defense_roll = tb_items.get_defense(attacker, defender) + defense_roll = tb_items.get_defense(self.attacker, self.defender) self.assertTrue(defense_roll == 50) # Damage roll - damage_roll = tb_items.get_damage(attacker, defender) + damage_roll = tb_items.get_damage(self.attacker, self.defender) self.assertTrue(damage_roll >= 15 and damage_roll <= 25) # Apply damage - defender.db.hp = 10 - tb_items.apply_damage(defender, 3) - self.assertTrue(defender.db.hp == 7) + self.defender.db.hp = 10 + tb_items.apply_damage(self.defender, 3) + self.assertTrue(self.defender.db.hp == 7) # Resolve attack - defender.db.hp = 40 - tb_items.resolve_attack(attacker, defender, attack_value=20, defense_value=10) - self.assertTrue(defender.db.hp < 40) + self.defender.db.hp = 40 + tb_items.resolve_attack(self.attacker, self.defender, attack_value=20, defense_value=10) + self.assertTrue(self.defender.db.hp < 40) # Combat cleanup - attacker.db.Combat_attribute = True - tb_items.combat_cleanup(attacker) - self.assertFalse(attacker.db.combat_attribute) + self.attacker.db.Combat_attribute = True + tb_items.combat_cleanup(self.attacker) + self.assertFalse(self.attacker.db.combat_attribute) # Is in combat - self.assertFalse(tb_items.is_in_combat(attacker)) + self.assertFalse(tb_items.is_in_combat(self.attacker)) # Set up turn handler script for further tests - attacker.location.scripts.add(tb_items.TBItemsTurnHandler) - turnhandler = attacker.db.combat_TurnHandler - self.assertTrue(attacker.db.combat_TurnHandler) + self.attacker.location.scripts.add(tb_items.TBItemsTurnHandler) + self.turnhandler = self.attacker.db.combat_TurnHandler + self.assertTrue(self.attacker.db.combat_TurnHandler) # Set the turn handler's interval very high to keep it from repeating during tests. - turnhandler.interval = 10000 + self.turnhandler.interval = 10000 # Force turn order - turnhandler.db.fighters = [attacker, defender] - turnhandler.db.turn = 0 + self.turnhandler.db.fighters = [self.attacker, self.defender] + self.turnhandler.db.turn = 0 # Test is turn - self.assertTrue(tb_items.is_turn(attacker)) + self.assertTrue(tb_items.is_turn(self.attacker)) # Spend actions - attacker.db.Combat_ActionsLeft = 1 - tb_items.spend_action(attacker, 1, action_name="Test") - self.assertTrue(attacker.db.Combat_ActionsLeft == 0) - self.assertTrue(attacker.db.Combat_LastAction == "Test") + self.attacker.db.Combat_ActionsLeft = 1 + tb_items.spend_action(self.attacker, 1, action_name="Test") + self.assertTrue(self.attacker.db.Combat_ActionsLeft == 0) + self.assertTrue(self.attacker.db.Combat_LastAction == "Test") # Initialize for combat - attacker.db.Combat_ActionsLeft = 983 - turnhandler.initialize_for_combat(attacker) - self.assertTrue(attacker.db.Combat_ActionsLeft == 0) - self.assertTrue(attacker.db.Combat_LastAction == "null") + self.attacker.db.Combat_ActionsLeft = 983 + self.turnhandler.initialize_for_combat(self.attacker) + self.assertTrue(self.attacker.db.Combat_ActionsLeft == 0) + self.assertTrue(self.attacker.db.Combat_LastAction == "null") # Start turn - defender.db.Combat_ActionsLeft = 0 - turnhandler.start_turn(defender) - self.assertTrue(defender.db.Combat_ActionsLeft == 1) + self.defender.db.Combat_ActionsLeft = 0 + self.turnhandler.start_turn(self.defender) + self.assertTrue(self.defender.db.Combat_ActionsLeft == 1) # Next turn - turnhandler.db.fighters = [attacker, defender] - turnhandler.db.turn = 0 - turnhandler.next_turn() - self.assertTrue(turnhandler.db.turn == 1) + self.turnhandler.db.fighters = [self.attacker, self.defender] + self.turnhandler.db.turn = 0 + self.turnhandler.next_turn() + self.assertTrue(self.turnhandler.db.turn == 1) # Turn end check - turnhandler.db.fighters = [attacker, defender] - turnhandler.db.turn = 0 - attacker.db.Combat_ActionsLeft = 0 - turnhandler.turn_end_check(attacker) - self.assertTrue(turnhandler.db.turn == 1) + self.turnhandler.db.fighters = [self.attacker, self.defender] + self.turnhandler.db.turn = 0 + self.attacker.db.Combat_ActionsLeft = 0 + self.turnhandler.turn_end_check(self.attacker) + self.assertTrue(self.turnhandler.db.turn == 1) # Join fight - joiner = create_object(tb_items.TBItemsCharacterTest, key="Joiner") - turnhandler.db.fighters = [attacker, defender] - turnhandler.db.turn = 0 - turnhandler.join_fight(joiner) - self.assertTrue(turnhandler.db.turn == 1) - self.assertTrue(turnhandler.db.fighters == [joiner, attacker, defender]) - # Remove the script at the end - turnhandler.stop() + self.turnhandler.db.fighters = [self.attacker, self.defender] + self.turnhandler.db.turn = 0 + self.turnhandler.join_fight(self.joiner) + self.assertTrue(self.turnhandler.db.turn == 1) + self.assertTrue(self.turnhandler.db.fighters == [self.joiner, self.attacker, self.defender]) # Now time to test item stuff. - user = create_object(tb_items.TBItemsCharacterTest, key="User") - testroom = create_object(DefaultRoom, key="Test Room") - user.location = testroom - test_healpotion = create_object(key="healing potion") - test_healpotion.db.item_func = "heal" - test_healpotion.db.item_uses = 3 # Spend item use - tb_items.spend_item_use(test_healpotion, user) - self.assertTrue(test_healpotion.db.item_uses == 2) + tb_items.spend_item_use(self.test_healpotion, self.user) + self.assertTrue(self.test_healpotion.db.item_uses == 2) # Use item - user.db.hp = 2 - tb_items.use_item(user, test_healpotion, user) - self.assertTrue(user.db.hp > 2) + self.user.db.hp = 2 + tb_items.use_item(self.user, self.test_healpotion, self.user) + self.assertTrue(self.user.db.hp > 2) # Add contition - tb_items.add_condition(user, user, "Test", 5) - self.assertTrue(user.db.conditions == {"Test":[5, user]}) + tb_items.add_condition(self.user, self.user, "Test", 5) + self.assertTrue(self.user.db.conditions == {"Test":[5, self.user]}) # Condition tickdown - tb_items.condition_tickdown(user, user) - self.assertTrue(user.db.conditions == {"Test":[4, user]}) + tb_items.condition_tickdown(self.user, self.user) + self.assertTrue(self.user.db.conditions == {"Test":[4, self.user]}) # Test item functions now! # Item heal - user.db.hp = 2 - tb_items.itemfunc_heal(test_healpotion, user, user) + self.user.db.hp = 2 + tb_items.itemfunc_heal(self.test_healpotion, self.user, self.user) # Item add condition - user.db.conditions = {} - tb_items.itemfunc_add_condition(test_healpotion, user, user) - self.assertTrue(user.db.conditions == {"Regeneration":[5, user]}) + self.user.db.conditions = {} + tb_items.itemfunc_add_condition(self.test_healpotion, self.user, self.user) + self.assertTrue(self.user.db.conditions == {"Regeneration":[5, self.user]}) # Item cure condition - user.db.conditions = {"Poisoned":[5, user]} - tb_items.itemfunc_cure_condition(test_healpotion, user, user) - self.assertTrue(user.db.conditions == {}) - # Delete the test character - user.delete() + self.user.db.conditions = {"Poisoned":[5, self.user]} + tb_items.itemfunc_cure_condition(self.test_healpotion, self.user, self.user) + self.assertTrue(self.user.db.conditions == {}) class TestTurnBattleMagicFunc(EvenniaTest): + + def setUp(self): + super(TestTurnBattleMagicFunc, self).setUp() + self.testroom = create_object(DefaultRoom, key="Test Room") + self.attacker = create_object(tb_magic.TBMagicCharacter, key="Attacker", location=self.testroom) + self.defender = create_object(tb_magic.TBMagicCharacter, key="Defender", location=self.testroom) + self.joiner = create_object(tb_magic.TBMagicCharacter, key="Joiner", location=self.testroom) + def tearDown(self): + super(TestTurnBattleMagicFunc, self).tearDown() + self.attacker.delete() + self.defender.delete() + self.joiner.delete() + self.testroom.delete() + self.turnhandler.stop() + # Test combat functions in tb_magic. def test_tbbasicfunc(self): - attacker = create_object(tb_magic.TBMagicCharacter, key="Attacker") - defender = create_object(tb_magic.TBMagicCharacter, key="Defender") - testroom = create_object(DefaultRoom, key="Test Room") - attacker.location = testroom - defender.loaction = testroom # Initiative roll - initiative = tb_magic.roll_init(attacker) + initiative = tb_magic.roll_init(self.attacker) self.assertTrue(initiative >= 0 and initiative <= 1000) # Attack roll - attack_roll = tb_magic.get_attack(attacker, defender) + attack_roll = tb_magic.get_attack(self.attacker, self.defender) self.assertTrue(attack_roll >= 0 and attack_roll <= 100) # Defense roll - defense_roll = tb_magic.get_defense(attacker, defender) + defense_roll = tb_magic.get_defense(self.attacker, self.defender) self.assertTrue(defense_roll == 50) # Damage roll - damage_roll = tb_magic.get_damage(attacker, defender) + damage_roll = tb_magic.get_damage(self.attacker, self.defender) self.assertTrue(damage_roll >= 15 and damage_roll <= 25) # Apply damage - defender.db.hp = 10 - tb_magic.apply_damage(defender, 3) - self.assertTrue(defender.db.hp == 7) + self.defender.db.hp = 10 + tb_magic.apply_damage(self.defender, 3) + self.assertTrue(self.defender.db.hp == 7) # Resolve attack - defender.db.hp = 40 - tb_magic.resolve_attack(attacker, defender, attack_value=20, defense_value=10) - self.assertTrue(defender.db.hp < 40) + self.defender.db.hp = 40 + tb_magic.resolve_attack(self.attacker, self.defender, attack_value=20, defense_value=10) + self.assertTrue(self.defender.db.hp < 40) # Combat cleanup - attacker.db.Combat_attribute = True - tb_magic.combat_cleanup(attacker) - self.assertFalse(attacker.db.combat_attribute) + self.attacker.db.Combat_attribute = True + tb_magic.combat_cleanup(self.attacker) + self.assertFalse(self.attacker.db.combat_attribute) # Is in combat - self.assertFalse(tb_magic.is_in_combat(attacker)) + self.assertFalse(tb_magic.is_in_combat(self.attacker)) # Set up turn handler script for further tests - attacker.location.scripts.add(tb_magic.TBMagicTurnHandler) - turnhandler = attacker.db.combat_TurnHandler - self.assertTrue(attacker.db.combat_TurnHandler) + self.attacker.location.scripts.add(tb_magic.TBMagicTurnHandler) + self.turnhandler = self.attacker.db.combat_TurnHandler + self.assertTrue(self.attacker.db.combat_TurnHandler) # Set the turn handler's interval very high to keep it from repeating during tests. - turnhandler.interval = 10000 + self.turnhandler.interval = 10000 # Force turn order - turnhandler.db.fighters = [attacker, defender] - turnhandler.db.turn = 0 + self.turnhandler.db.fighters = [self.attacker, self.defender] + self.turnhandler.db.turn = 0 # Test is turn - self.assertTrue(tb_magic.is_turn(attacker)) + self.assertTrue(tb_magic.is_turn(self.attacker)) # Spend actions - attacker.db.Combat_ActionsLeft = 1 - tb_magic.spend_action(attacker, 1, action_name="Test") - self.assertTrue(attacker.db.Combat_ActionsLeft == 0) - self.assertTrue(attacker.db.Combat_LastAction == "Test") + self.attacker.db.Combat_ActionsLeft = 1 + tb_magic.spend_action(self.attacker, 1, action_name="Test") + self.assertTrue(self.attacker.db.Combat_ActionsLeft == 0) + self.assertTrue(self.attacker.db.Combat_LastAction == "Test") # Initialize for combat - attacker.db.Combat_ActionsLeft = 983 - turnhandler.initialize_for_combat(attacker) - self.assertTrue(attacker.db.Combat_ActionsLeft == 0) - self.assertTrue(attacker.db.Combat_LastAction == "null") + self.attacker.db.Combat_ActionsLeft = 983 + self.turnhandler.initialize_for_combat(self.attacker) + self.assertTrue(self.attacker.db.Combat_ActionsLeft == 0) + self.assertTrue(self.attacker.db.Combat_LastAction == "null") # Start turn - defender.db.Combat_ActionsLeft = 0 - turnhandler.start_turn(defender) - self.assertTrue(defender.db.Combat_ActionsLeft == 1) + self.defender.db.Combat_ActionsLeft = 0 + self.turnhandler.start_turn(self.defender) + self.assertTrue(self.defender.db.Combat_ActionsLeft == 1) # Next turn - turnhandler.db.fighters = [attacker, defender] - turnhandler.db.turn = 0 - turnhandler.next_turn() - self.assertTrue(turnhandler.db.turn == 1) + self.turnhandler.db.fighters = [self.attacker, self.defender] + self.turnhandler.db.turn = 0 + self.turnhandler.next_turn() + self.assertTrue(self.turnhandler.db.turn == 1) # Turn end check - turnhandler.db.fighters = [attacker, defender] - turnhandler.db.turn = 0 - attacker.db.Combat_ActionsLeft = 0 - turnhandler.turn_end_check(attacker) - self.assertTrue(turnhandler.db.turn == 1) + self.turnhandler.db.fighters = [self.attacker, self.defender] + self.turnhandler.db.turn = 0 + self.attacker.db.Combat_ActionsLeft = 0 + self.turnhandler.turn_end_check(self.attacker) + self.assertTrue(self.turnhandler.db.turn == 1) # Join fight - joiner = create_object(tb_magic.TBMagicCharacter, key="Joiner") - turnhandler.db.fighters = [attacker, defender] - turnhandler.db.turn = 0 - turnhandler.join_fight(joiner) - self.assertTrue(turnhandler.db.turn == 1) - self.assertTrue(turnhandler.db.fighters == [joiner, attacker, defender]) - # Remove the script at the end - turnhandler.stop() + self.turnhandler.db.fighters = [self.attacker, self.defender] + self.turnhandler.db.turn = 0 + self.turnhandler.join_fight(self.joiner) + self.assertTrue(self.turnhandler.db.turn == 1) + self.assertTrue(self.turnhandler.db.fighters == [self.joiner, self.attacker, self.defender]) # Test tree select From 37c679d2f24e9e40a3bad407579f48a1ddc477fc Mon Sep 17 00:00:00 2001 From: Griatch Date: Tue, 22 May 2018 22:28:03 +0200 Subject: [PATCH 129/208] Update-objs with prototype, first version, no testing yet --- evennia/typeclasses/attributes.py | 1 + evennia/utils/spawner.py | 159 +++++++++++++++++++++++++++++- 2 files changed, 155 insertions(+), 5 deletions(-) diff --git a/evennia/typeclasses/attributes.py b/evennia/typeclasses/attributes.py index 4ed68a1fe8..eb698e6f0e 100644 --- a/evennia/typeclasses/attributes.py +++ b/evennia/typeclasses/attributes.py @@ -435,6 +435,7 @@ class AttributeHandler(object): def __init__(self): self.key = None self.value = default + self.category = None self.strvalue = str(default) if default is not None else None ret = [] diff --git a/evennia/utils/spawner.py b/evennia/utils/spawner.py index 06cb59c178..3c269ca742 100644 --- a/evennia/utils/spawner.py +++ b/evennia/utils/spawner.py @@ -235,7 +235,7 @@ def validate_spawn_value(value, validator=None): Analyze the value and produce a value for use at the point of spawning. Args: - value (any): This can be:j + value (any): This can be: callable - will be called as callable() (callable, (args,)) - will be called as callable(*args) other - will be assigned depending on the variable type @@ -602,6 +602,44 @@ def prototype_to_str(prototype): return header + proto +def prototype_from_object(obj): + """ + Guess a minimal prototype from an existing object. + + Args: + obj (Object): An object to analyze. + + Returns: + prototype (dict): A prototype estimating the current state of the object. + + """ + # first, check if this object already has a prototype + + prot = obj.tags.get(category=_PROTOTYPE_TAG_CATEGORY, return_list=True) + prot = search_prototype(prot) + if not prot or len(prot) > 1: + # no unambiguous prototype found - build new prototype + prot = {} + prot['prototype_key'] = "From-Object-{}-{}".format( + obj.key, hashlib.md5(str(time.time())).hexdigest()[:6]) + prot['prototype_desc'] = "Built from {}".format(str(obj)) + prot['prototype_locks'] = "use:all();edit:all()" + + prot['key'] = obj.db_key or hashlib.md5(str(time.time())).hexdigest()[:6] + prot['location'] = obj.db_location + prot['home'] = obj.db_home + prot['destination'] = obj.db_destination + prot['typeclass'] = obj.db_typeclass_path + prot['locks'] = obj.locks.all() + prot['permissions'] = obj.permissions.get() + prot['aliases'] = obj.aliases.get() + prot['tags'] = [(tag.key, tag.category, tag.data) + for tag in obj.tags.get(return_tagobj=True, return_list=True)] + prot['attrs'] = [(attr.key, attr.value, attr.category, attr.locks) + for attr in obj.attributes.get(return_obj=True, return_list=True)] + + return prot + # Spawner mechanism @@ -665,26 +703,137 @@ def _get_prototype(dic, prot, protparents): return prot -def batch_update_objects_with_prototype(prototype, objects=None): +def prototype_diff_from_object(prototype, obj): + """ + Get a simple diff for a prototype compared to an object which may or may not already have a + prototype (or has one but changed locally). For more complex migratations a manual diff may be + needed. + + Args: + prototype (dict): Prototype. + obj (Object): Object to + + Returns: + diff (dict): Mapping for every prototype key: {"keyname": "REMOVE|UPDATE|KEEP", ...} + + """ + prot1 = prototype + prot2 = prototype_from_object(obj) + + diff = {} + for key, value in prot1.items(): + diff[key] = "KEEP" + if key in prot2: + if callable(prot2[key]) or value != prot2[key]: + diff[key] = "UPDATE" + elif key not in prot2: + diff[key] = "REMOVE" + + return diff + + +def batch_update_objects_with_prototype(prototype, diff=None, objects=None): """ Update existing objects with the latest version of the prototype. Args: prototype (str or dict): Either the `prototype_key` to use or the prototype dict itself. - objects (list): List of objects to update. If not given, query for these + diff (dict, optional): This a diff structure that describes how to update the protototype. If + not given this will be constructed from the first object found. + objects (list, optional): List of objects to update. If not given, query for these objects using the prototype's `prototype_key`. Returns: changed (int): The number of objects that had changes applied to them. + """ prototype_key = prototype if isinstance(prototype, basestring) else prototype['prototype_key'] prototype_obj = search_db_prototype(prototype_key, return_queryset=True) prototype_obj = prototype_obj[0] if prototype_obj else None new_prototype = prototype_obj.db.prototype + objs = ObjectDB.objects.get_by_tag(prototype_key, category=_PROTOTYPE_TAG_CATEGORY) + if not objs: + return 0 + if not diff: + diff = prototype_diff_from_object(new_prototype, objs[0]) - return 0 + changed = 0 + for obj in objs: + do_save = False + for key, directive in diff.items(): + val = new_prototype[key] + if directive in ('UPDATE', 'REPLACE'): + do_save = True + if key == 'key': + obj.db_key = validate_spawn_value(val, str) + elif key == 'typeclass': + obj.db_typeclass_path = validate_spawn_value(val, str) + elif key == 'location': + obj.db_location = validate_spawn_value(val, _to_obj) + elif key == 'home': + obj.db_home = validate_spawn_value(val, _to_obj) + elif key == 'destination': + obj.db_destination = validate_spawn_value(val, _to_obj) + elif key == 'locks': + if directive == 'REPLACE': + obj.locks.clear() + obj.locks.add(validate_spawn_value(val, str)) + elif key == 'permissions': + if directive == 'REPLACE': + obj.permissions.clear() + obj.permissions.batch_add(validate_spawn_value(val, make_iter)) + elif key == 'aliases': + if directive == 'REPLACE': + obj.aliases.clear() + obj.aliases.batch_add(validate_spawn_value(val, make_iter)) + elif key == 'tags': + if directive == 'REPLACE': + obj.tags.clear() + obj.tags.batch_add(validate_spawn_value(val, make_iter)) + elif key == 'attrs': + if directive == 'REPLACE': + obj.attributes.clear() + obj.attributes.batch_add(validate_spawn_value(val, make_iter)) + elif key == 'exec': + # we don't auto-rerun exec statements, it would be huge security risk! + pass + else: + obj.attributes.add(key, validate_spawn_value(val, _to_obj)) + elif directive == 'REMOVE': + do_save = True + if key == 'key': + obj.db_key = '' + elif key == 'typeclass': + # fall back to default + obj.db_typeclass_path = settings.BASE_OBJECT_TYPECLASS + elif key == 'location': + obj.db_location = None + elif key == 'home': + obj.db_home = None + elif key == 'destination': + obj.db_destination = None + elif key == 'locks': + obj.locks.clear() + elif key == 'permissions': + obj.permissions.clear() + elif key == 'aliases': + obj.aliases.clear() + elif key == 'tags': + obj.tags.clear() + elif key == 'attrs': + obj.attributes.clear() + elif key == 'exec': + # we don't auto-rerun exec statements, it would be huge security risk! + pass + else: + obj.attributes.remove(key) + if do_save: + changed += 1 + obj.save() + + return changed def _batch_create_object(*objparams): @@ -835,7 +984,7 @@ def spawn(*prototypes, **kwargs): execs = validate_spawn_value(val, make_iter) # extract ndb assignments - nattributes = dict((key.split("_", 1)[1], validate_spawn_value(val, _to_obj)) + nattribute = dict((key.split("_", 1)[1], validate_spawn_value(val, _to_obj)) for key, val in prot.items() if key.startswith("ndb_")) # the rest are attributes From 26205acc39bba538e794f14d0a2d01e3eb6c2552 Mon Sep 17 00:00:00 2001 From: Tehom Date: Tue, 29 May 2018 23:05:34 -0400 Subject: [PATCH 130/208] Fix __add__ in SaverList --- evennia/utils/dbserialize.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/evennia/utils/dbserialize.py b/evennia/utils/dbserialize.py index 80203923e8..c08704be0e 100644 --- a/evennia/utils/dbserialize.py +++ b/evennia/utils/dbserialize.py @@ -237,10 +237,13 @@ class _SaverList(_SaverMutable, MutableSequence): self._data = list() @_save - def __add__(self, otherlist): + def __iadd__(self, otherlist): self._data = self._data.__add__(otherlist) return self._data + def __add__(self, otherlist): + return list(self._data) + otherlist + @_save def insert(self, index, value): self._data.insert(index, self._convert_mutables(value)) From 7c4a9a03e0b17d272177018d2eb9a8bd6467b888 Mon Sep 17 00:00:00 2001 From: Griatch Date: Wed, 6 Jun 2018 19:15:20 +0200 Subject: [PATCH 131/208] Refactor prototype-functionality into its own package --- evennia/locks/lockhandler.py | 16 + evennia/prototypes/README.md | 145 +++ evennia/prototypes/__init__.py | 0 evennia/prototypes/menus.py | 709 ++++++++++++ evennia/prototypes/protfuncs.py | 78 ++ evennia/prototypes/prototypes.py | 280 +++++ evennia/prototypes/spawner.py | 600 ++++++++++ evennia/prototypes/utils.py | 150 +++ evennia/utils/spawner.py | 1752 ------------------------------ 9 files changed, 1978 insertions(+), 1752 deletions(-) create mode 100644 evennia/prototypes/README.md create mode 100644 evennia/prototypes/__init__.py create mode 100644 evennia/prototypes/menus.py create mode 100644 evennia/prototypes/protfuncs.py create mode 100644 evennia/prototypes/prototypes.py create mode 100644 evennia/prototypes/spawner.py create mode 100644 evennia/prototypes/utils.py delete mode 100644 evennia/utils/spawner.py diff --git a/evennia/locks/lockhandler.py b/evennia/locks/lockhandler.py index c65b30c131..4822dde1b6 100644 --- a/evennia/locks/lockhandler.py +++ b/evennia/locks/lockhandler.py @@ -647,6 +647,22 @@ def check_lockstring(self, accessing_obj, lockstring, no_superuser_bypass=False, default=default, access_type=access_type) +def validate_lockstring(lockstring): + """ + Validate so lockstring is on a valid form. + + Args: + lockstring (str): Lockstring to validate. + + Returns: + is_valid (bool): If the lockstring is valid or not. + error (str or None): A string describing the error, or None + if no error was found. + + """ + return _LOCK_HANDLER.valdate(lockstring) + + def _test(): # testing diff --git a/evennia/prototypes/README.md b/evennia/prototypes/README.md new file mode 100644 index 0000000000..0f4139aa3e --- /dev/null +++ b/evennia/prototypes/README.md @@ -0,0 +1,145 @@ +# Prototypes + +A 'Prototype' is a normal Python dictionary describing unique features of individual instance of a +Typeclass. The prototype is used to 'spawn' a new instance with custom features detailed by said +prototype. This allows for creating variations without having to create a large number of actual +Typeclasses. It is a good way to allow Builders more freedom of creation without giving them full +Python access to create Typeclasses. + +For example, if a Typeclass 'Cat' describes all the coded differences between a Cat and +other types of animals, then prototypes could be used to quickly create unique individual cats with +different Attributes/properties (like different colors, stats, names etc) without having to make a new +Typeclass for each. Prototypes have inheritance and can be scripted when they are applied to create +a new instance of a typeclass - a common example would be to randomize stats and name. + +The prototype is a normal dictionary with specific keys. Almost all values can be callables +triggered when the prototype is used to spawn a new instance. Below is an example: + +``` +{ +# meta-keys - these are used only when listing prototypes in-game. Only prototype_key is mandatory, +# but it must be globally unique. + + "prototype_key": "base_goblin", + "prototype_desc": "A basic goblin", + "prototype_locks": "edit:all();spawn:all()", + "prototype_tags": "mobs", + +# fixed-meaning keys, modifying the spawned instance. 'typeclass' may be +# replaced by 'parent', referring to the prototype_key of an existing prototype +# to inherit from. + + "typeclass": "types.objects.Monster", + "key": "goblin grunt", + "tags": ["mob", "evil", ('greenskin','mob')] # tags as well as tags with category etc + "attrs": [("weapon", "sword")] # this allows to set Attributes with categories etc + +# non-fixed keys are interpreted as Attributes and their + + "health": lambda: randint(20,30), + "resists": ["cold", "poison"], + "attacks": ["fists"], + "weaknesses": ["fire", "light"] + } + +``` +## Using prototypes + +Prototypes are generally used as inputs to the `spawn` command: + + @spawn prototype_key + +This will spawn a new instance of the prototype in the caller's current location unless the +`location` key of the prototype was set (see below). The caller must pass the prototype's 'spawn' +lock to be able to use it. + + @spawn/list [prototype_key] + +will show all available prototypes along with meta info, or look at a specific prototype in detail. + + +## Creating prototypes + +The `spawn` command can also be used to directly create/update prototypes from in-game. + + spawn/save {"prototype_key: "goblin", ... } + +but it is probably more convenient to use the menu-driven prototype wizard: + + spawn/menu goblin + +In code: + +```python + +from evennia import prototypes + +goblin = {"prototype_key": "goblin:, ... } + +prototype = prototypes.save_prototype(caller, **goblin) + +``` + +Prototypes will normally be stored in the database (internally this is done using a Script, holding +the meta-info and the prototype). One can also define prototypes outside of the game by assigning +the prototype dictionary to a global variable in a module defined by `settings.PROTOTYPE_MODULES`: + +```python +# in e.g. mygame/world/prototypes.py + +GOBLIN = { + "prototype_key": "goblin", + ... + } + +``` + +Such prototypes cannot be modified from inside the game no matter what `edit` lock they are given +(we refer to them as 'readonly') but can be a fast and efficient way to give builders a starting +library of prototypes to inherit from. + +## Valid Prototype keys + +Every prototype key also accepts a callable (taking no arguments) for producing its value or a +string with an $protfunc definition. That callable/protfunc must then return a value on a form the +prototype key expects. + + - `prototype_key` (str): name of this prototype. This is used when storing prototypes and should + be unique. This should always be defined but for prototypes defined in modules, the + variable holding the prototype dict will become the prototype_key if it's not explicitly + given. + - `prototype_desc` (str, optional): describes prototype in listings + - `prototype_locks` (str, optional): locks for restricting access to this prototype. Locktypes + supported are 'edit' and 'use'. + - `prototype_tags` (list, optional): List of tags or tuples (tag, category) used to group prototype + in listings + + - `parent` (str or tuple, optional): name (`prototype_key`) of eventual parent prototype, or a + list of parents for multiple left-to-right inheritance. + - `prototype`: Deprecated. Same meaning as 'parent'. + - `typeclass` (str, optional): if not set, will use typeclass of parent prototype or use + `settings.BASE_OBJECT_TYPECLASS` + - `key` (str, optional): the name of the spawned object. If not given this will set to a + random hash + - `location` (obj, optional): location of the object - a valid object or #dbref + - `home` (obj or str, optional): valid object or #dbref + - `destination` (obj or str, optional): only valid for exits (object or #dbref) + + - `permissions` (str or list, optional): which permissions for spawned object to have + - `locks` (str, optional): lock-string for the spawned object + - `aliases` (str or list, optional): Aliases for the spawned object. + - `exec` (str, optional): this is a string of python code to execute or a list of such + codes. This can be used e.g. to trigger custom handlers on the object. The execution + namespace contains 'evennia' for the library and 'obj'. All default spawn commands limit + this functionality to Developer/superusers. Usually it's better to use callables or + prototypefuncs instead of this. + - `tags` (str, tuple or list, optional): string or list of strings or tuples + `(tagstr, category)`. Plain strings will be result in tags with no category (default tags). + - `attrs` (tuple or list, optional): tuple or list of tuples of Attributes to add. This + form allows more complex Attributes to be set. Tuples at least specify `(key, value)` + but can also specify up to `(key, value, category, lockstring)`. If you want to specify a + lockstring but not a category, set the category to `None`. + - `ndb_` (any): value of a nattribute (`ndb_` is stripped). This is usually not useful to + put in a prototype unless the NAttribute is used immediately upon spawning. + - `other` (any): any other name is interpreted as the key of an Attribute with + its value. Such Attributes have no categories. diff --git a/evennia/prototypes/__init__.py b/evennia/prototypes/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/evennia/prototypes/menus.py b/evennia/prototypes/menus.py new file mode 100644 index 0000000000..85e7f3f574 --- /dev/null +++ b/evennia/prototypes/menus.py @@ -0,0 +1,709 @@ +""" + +OLC Prototype menu nodes + +""" + +from evennia.utils.evmenu import EvMenu, list_node +from evennia.utils.ansi import strip_ansi + +# ------------------------------------------------------------ +# +# OLC Prototype design menu +# +# ------------------------------------------------------------ + +# Helper functions + + +def _get_menu_prototype(caller): + + prototype = None + if hasattr(caller.ndb._menutree, "olc_prototype"): + prototype = caller.ndb._menutree.olc_prototype + if not prototype: + caller.ndb._menutree.olc_prototype = prototype = {} + caller.ndb._menutree.olc_new = True + return prototype + + +def _is_new_prototype(caller): + return hasattr(caller.ndb._menutree, "olc_new") + + +def _set_menu_prototype(caller, field, value): + prototype = _get_menu_prototype(caller) + prototype[field] = value + caller.ndb._menutree.olc_prototype = prototype + + +def _format_property(prop, required=False, prototype=None, cropper=None): + + if prototype is not None: + prop = prototype.get(prop, '') + + out = prop + if callable(prop): + if hasattr(prop, '__name__'): + out = "<{}>".format(prop.__name__) + else: + out = repr(prop) + if is_iter(prop): + out = ", ".join(str(pr) for pr in prop) + if not out and required: + out = "|rrequired" + return " ({}|n)".format(cropper(out) if cropper else crop(out, _MENU_CROP_WIDTH)) + + +def _set_property(caller, raw_string, **kwargs): + """ + Update a property. To be called by the 'goto' option variable. + + Args: + caller (Object, Account): The user of the wizard. + raw_string (str): Input from user on given node - the new value to set. + Kwargs: + prop (str): Property name to edit with `raw_string`. + processor (callable): Converts `raw_string` to a form suitable for saving. + next_node (str): Where to redirect to after this has run. + Returns: + next_node (str): Next node to go to. + + """ + prop = kwargs.get("prop", "prototype_key") + processor = kwargs.get("processor", None) + next_node = kwargs.get("next_node", "node_index") + + propname_low = prop.strip().lower() + + if callable(processor): + try: + value = processor(raw_string) + except Exception as err: + caller.msg("Could not set {prop} to {value} ({err})".format( + prop=prop.replace("_", "-").capitalize(), value=raw_string, err=str(err))) + # this means we'll re-run the current node. + return None + else: + value = raw_string + + if not value: + return next_node + + prototype = _get_menu_prototype(caller) + + # typeclass and prototype can't co-exist + if propname_low == "typeclass": + prototype.pop("prototype", None) + if propname_low == "prototype": + prototype.pop("typeclass", None) + + caller.ndb._menutree.olc_prototype = prototype + + caller.msg("Set {prop} to '{value}'.".format(prop, value=str(value))) + + return next_node + + +def _wizard_options(curr_node, prev_node, next_node, color="|W"): + options = [] + if prev_node: + options.append({"key": ("|wb|Wack", "b"), + "desc": "{color}({node})|n".format( + color=color, node=prev_node.replace("_", "-")), + "goto": "node_{}".format(prev_node)}) + if next_node: + options.append({"key": ("|wf|Worward", "f"), + "desc": "{color}({node})|n".format( + color=color, node=next_node.replace("_", "-")), + "goto": "node_{}".format(next_node)}) + + if "index" not in (prev_node, next_node): + options.append({"key": ("|wi|Wndex", "i"), + "goto": "node_index"}) + + if curr_node: + options.append({"key": ("|wv|Walidate prototype", "v"), + "goto": ("node_validate_prototype", {"back": curr_node})}) + + return options + + +def _path_cropper(pythonpath): + "Crop path to only the last component" + return pythonpath.split('.')[-1] + + +# Menu nodes + +def node_index(caller): + prototype = _get_menu_prototype(caller) + + text = ("|c --- Prototype wizard --- |n\n\n" + "Define the |yproperties|n of the prototype. All prototype values can be " + "over-ridden at the time of spawning an instance of the prototype, but some are " + "required.\n\n'|wMeta'-properties|n are not used in the prototype itself but are used " + "to organize and list prototypes. The 'Meta-Key' uniquely identifies the prototype " + "and allows you to edit an existing prototype or save a new one for use by you or " + "others later.\n\n(make choice; q to abort. If unsure, start from 1.)") + + options = [] + options.append( + {"desc": "|WPrototype-Key|n|n{}".format(_format_property("Key", True, prototype, None)), + "goto": "node_prototype_key"}) + for key in ('Prototype', 'Typeclass', 'Key', 'Aliases', 'Attrs', 'Tags', 'Locks', + 'Permissions', 'Location', 'Home', 'Destination'): + required = False + cropper = None + if key in ("Prototype", "Typeclass"): + required = "prototype" not in prototype and "typeclass" not in prototype + if key == 'Typeclass': + cropper = _path_cropper + options.append( + {"desc": "|w{}|n{}".format( + key, _format_property(key, required, prototype, cropper=cropper)), + "goto": "node_{}".format(key.lower())}) + required = False + for key in ('Desc', 'Tags', 'Locks'): + options.append( + {"desc": "|WPrototype-{}|n|n{}".format(key, _format_property(key, required, prototype, None)), + "goto": "node_prototype_{}".format(key.lower())}) + + return text, options + + +def node_validate_prototype(caller, raw_string, **kwargs): + prototype = _get_menu_prototype(caller) + + txt = prototype_to_str(prototype) + errors = "\n\n|g No validation errors found.|n (but errors could still happen at spawn-time)" + try: + # validate, don't spawn + spawn(prototype, return_prototypes=True) + except RuntimeError as err: + errors = "\n\n|rError: {}|n".format(err) + text = (txt + errors) + + options = _wizard_options(None, kwargs.get("back"), None) + + return text, options + + +def _check_prototype_key(caller, key): + old_prototype = search_prototype(key) + olc_new = _is_new_prototype(caller) + key = key.strip().lower() + if old_prototype: + # we are starting a new prototype that matches an existing + if not caller.locks.check_lockstring( + caller, old_prototype['prototype_locks'], access_type='edit'): + # return to the node_prototype_key to try another key + caller.msg("Prototype '{key}' already exists and you don't " + "have permission to edit it.".format(key=key)) + return "node_prototype_key" + elif olc_new: + # we are selecting an existing prototype to edit. Reset to index. + del caller.ndb._menutree.olc_new + caller.ndb._menutree.olc_prototype = old_prototype + caller.msg("Prototype already exists. Reloading.") + return "node_index" + + return _set_property(caller, key, prop='prototype_key', next_node="node_prototype") + + +def node_prototype_key(caller): + prototype = _get_menu_prototype(caller) + text = ["The prototype name, or |wMeta-Key|n, uniquely identifies the prototype. " + "It is used to find and use the prototype to spawn new entities. " + "It is not case sensitive."] + old_key = prototype.get('prototype_key', None) + if old_key: + text.append("Current key is '|w{key}|n'".format(key=old_key)) + else: + text.append("The key is currently unset.") + text.append("Enter text or make a choice (q for quit)") + text = "\n\n".join(text) + options = _wizard_options("prototype_key", "index", "prototype") + options.append({"key": "_default", + "goto": _check_prototype_key}) + return text, options + + +def _all_prototypes(caller): + return [prototype["prototype_key"] + for prototype in search_prototype() if "prototype_key" in prototype] + + +def _prototype_examine(caller, prototype_name): + prototypes = search_prototype(key=prototype_name) + if prototypes: + caller.msg(prototype_to_str(prototypes[0])) + caller.msg("Prototype not registered.") + return None + + +def _prototype_select(caller, prototype): + ret = _set_property(caller, prototype, prop="prototype", processor=str, next_node="node_key") + caller.msg("Selected prototype |y{}|n. Removed any set typeclass parent.".format(prototype)) + return ret + + +@list_node(_all_prototypes, _prototype_select) +def node_prototype(caller): + prototype = _get_menu_prototype(caller) + + prot_parent_key = prototype.get('prototype') + + text = ["Set the prototype's |yParent Prototype|n. If this is unset, Typeclass will be used."] + if prot_parent_key: + prot_parent = search_prototype(prot_parent_key) + if prot_parent: + text.append("Current parent prototype is {}:\n{}".format(prototype_to_str(prot_parent))) + else: + text.append("Current parent prototype |r{prototype}|n " + "does not appear to exist.".format(prot_parent_key)) + else: + text.append("Parent prototype is not set") + text = "\n\n".join(text) + options = _wizard_options("prototype", "prototype_key", "typeclass", color="|W") + options.append({"key": "_default", + "goto": _prototype_examine}) + + return text, options + + +def _all_typeclasses(caller): + return list(sorted(get_all_typeclasses().keys())) + + +def _typeclass_examine(caller, typeclass_path): + if typeclass_path is None: + # this means we are exiting the listing + return "node_key" + + typeclass = get_all_typeclasses().get(typeclass_path) + if typeclass: + docstr = [] + for line in typeclass.__doc__.split("\n"): + if line.strip(): + docstr.append(line) + elif docstr: + break + docstr = '\n'.join(docstr) if docstr else "" + txt = "Typeclass |y{typeclass_path}|n; First paragraph of docstring:\n\n{docstring}".format( + typeclass_path=typeclass_path, docstring=docstr) + else: + txt = "This is typeclass |y{}|n.".format(typeclass) + caller.msg(txt) + return None + + +def _typeclass_select(caller, typeclass): + ret = _set_property(caller, typeclass, prop='typeclass', processor=str, next_node="node_key") + caller.msg("Selected typeclass |y{}|n. Removed any set prototype parent.".format(typeclass)) + return ret + + +@list_node(_all_typeclasses, _typeclass_select) +def node_typeclass(caller): + prototype = _get_menu_prototype(caller) + typeclass = prototype.get("typeclass") + + text = ["Set the typeclass's parent |yTypeclass|n."] + if typeclass: + text.append("Current typeclass is |y{typeclass}|n.".format(typeclass=typeclass)) + else: + text.append("Using default typeclass {typeclass}.".format( + typeclass=settings.BASE_OBJECT_TYPECLASS)) + text = "\n\n".join(text) + options = _wizard_options("typeclass", "prototype", "key", color="|W") + options.append({"key": "_default", + "goto": _typeclass_examine}) + return text, options + + +def node_key(caller): + prototype = _get_menu_prototype(caller) + key = prototype.get("key") + + text = ["Set the prototype's |yKey|n. This will retain case sensitivity."] + if key: + text.append("Current key value is '|y{key}|n'.".format(key=key)) + else: + text.append("Key is currently unset.") + text = "\n\n".join(text) + options = _wizard_options("key", "typeclass", "aliases") + options.append({"key": "_default", + "goto": (_set_property, + dict(prop="key", + processor=lambda s: s.strip(), + next_node="node_aliases"))}) + return text, options + + +def node_aliases(caller): + prototype = _get_menu_prototype(caller) + aliases = prototype.get("aliases") + + text = ["Set the prototype's |yAliases|n. Separate multiple aliases with commas. " + "ill retain case sensitivity."] + if aliases: + text.append("Current aliases are '|y{aliases}|n'.".format(aliases=aliases)) + else: + text.append("No aliases are set.") + text = "\n\n".join(text) + options = _wizard_options("aliases", "key", "attrs") + options.append({"key": "_default", + "goto": (_set_property, + dict(prop="aliases", + processor=lambda s: [part.strip() for part in s.split(",")], + next_node="node_attrs"))}) + return text, options + + +def _caller_attrs(caller): + prototype = _get_menu_prototype(caller) + attrs = prototype.get("attrs", []) + return attrs + + +def _attrparse(caller, attr_string): + "attr is entering on the form 'attr = value'" + + if '=' in attr_string: + attrname, value = (part.strip() for part in attr_string.split('=', 1)) + attrname = attrname.lower() + if attrname: + try: + value = literal_eval(value) + except SyntaxError: + caller.msg(_MENU_ATTR_LITERAL_EVAL_ERROR) + else: + return attrname, value + else: + return None, None + + +def _add_attr(caller, attr_string, **kwargs): + attrname, value = _attrparse(caller, attr_string) + if attrname: + prot = _get_menu_prototype(caller) + prot['attrs'][attrname] = value + _set_menu_prototype(caller, "prototype", prot) + text = "Added" + else: + text = "Attribute must be given as 'attrname = ' where uses valid Python." + options = {"key": "_default", + "goto": lambda caller: None} + return text, options + + +def _edit_attr(caller, attrname, new_value, **kwargs): + attrname, value = _attrparse("{}={}".format(caller, attrname, new_value)) + if attrname: + prot = _get_menu_prototype(caller) + prot['attrs'][attrname] = value + text = "Edited Attribute {} = {}".format(attrname, value) + else: + text = "Attribute value must be valid Python." + options = {"key": "_default", + "goto": lambda caller: None} + return text, options + + +def _examine_attr(caller, selection): + prot = _get_menu_prototype(caller) + value = prot['attrs'][selection] + return "Attribute {} = {}".format(selection, value) + + +@list_node(_caller_attrs) +def node_attrs(caller): + prot = _get_menu_prototype(caller) + attrs = prot.get("attrs") + + text = ["Set the prototype's |yAttributes|n. Separate multiple attrs with commas. " + "Will retain case sensitivity."] + if attrs: + text.append("Current attrs are '|y{attrs}|n'.".format(attrs=attrs)) + else: + text.append("No attrs are set.") + text = "\n\n".join(text) + options = _wizard_options("attrs", "aliases", "tags") + options.append({"key": "_default", + "goto": (_set_property, + dict(prop="attrs", + processor=lambda s: [part.strip() for part in s.split(",")], + next_node="node_tags"))}) + return text, options + + +def _caller_tags(caller): + prototype = _get_menu_prototype(caller) + tags = prototype.get("tags") + return tags + + +def _add_tag(caller, tag, **kwargs): + tag = tag.strip().lower() + prototype = _get_menu_prototype(caller) + tags = prototype.get('tags', []) + if tags: + if tag not in tags: + tags.append(tag) + else: + tags = [tag] + prot['tags'] = tags + _set_menu_prototype(caller, "prototype", prot) + text = kwargs.get("text") + if not text: + text = "Added tag {}. (return to continue)".format(tag) + options = {"key": "_default", + "goto": lambda caller: None} + return text, options + + +def _edit_tag(caller, old_tag, new_tag, **kwargs): + prototype = _get_menu_prototype(caller) + tags = prototype.get('tags', []) + + old_tag = old_tag.strip().lower() + new_tag = new_tag.strip().lower() + tags[tags.index(old_tag)] = new_tag + prototype['tags'] = tags + _set_menu_prototype(caller, 'prototype', prototype) + + text = kwargs.get('text') + if not text: + text = "Changed tag {} to {}.".format(old_tag, new_tag) + options = {"key": "_default", + "goto": lambda caller: None} + return text, options + + +@list_node(_caller_tags) +def node_tags(caller): + text = "Set the prototype's |yTags|n." + options = _wizard_options("tags", "attrs", "locks") + return text, options + + +def node_locks(caller): + prototype = _get_menu_prototype(caller) + locks = prototype.get("locks") + + text = ["Set the prototype's |yLock string|n. Separate multiple locks with semi-colons. " + "Will retain case sensitivity."] + if locks: + text.append("Current locks are '|y{locks}|n'.".format(locks=locks)) + else: + text.append("No locks are set.") + text = "\n\n".join(text) + options = _wizard_options("locks", "tags", "permissions") + options.append({"key": "_default", + "goto": (_set_property, + dict(prop="locks", + processor=lambda s: s.strip(), + next_node="node_permissions"))}) + return text, options + + +def node_permissions(caller): + prototype = _get_menu_prototype(caller) + permissions = prototype.get("permissions") + + text = ["Set the prototype's |yPermissions|n. Separate multiple permissions with commas. " + "Will retain case sensitivity."] + if permissions: + text.append("Current permissions are '|y{permissions}|n'.".format(permissions=permissions)) + else: + text.append("No permissions are set.") + text = "\n\n".join(text) + options = _wizard_options("permissions", "destination", "location") + options.append({"key": "_default", + "goto": (_set_property, + dict(prop="permissions", + processor=lambda s: [part.strip() for part in s.split(",")], + next_node="node_location"))}) + return text, options + + +def node_location(caller): + prototype = _get_menu_prototype(caller) + location = prototype.get("location") + + text = ["Set the prototype's |yLocation|n"] + if location: + text.append("Current location is |y{location}|n.".format(location=location)) + else: + text.append("Default location is {}'s inventory.".format(caller)) + text = "\n\n".join(text) + options = _wizard_options("location", "permissions", "home") + options.append({"key": "_default", + "goto": (_set_property, + dict(prop="location", + processor=lambda s: s.strip(), + next_node="node_home"))}) + return text, options + + +def node_home(caller): + prototype = _get_menu_prototype(caller) + home = prototype.get("home") + + text = ["Set the prototype's |yHome location|n"] + if home: + text.append("Current home location is |y{home}|n.".format(home=home)) + else: + text.append("Default home location (|y{home}|n) used.".format(home=settings.DEFAULT_HOME)) + text = "\n\n".join(text) + options = _wizard_options("home", "aliases", "destination") + options.append({"key": "_default", + "goto": (_set_property, + dict(prop="home", + processor=lambda s: s.strip(), + next_node="node_destination"))}) + return text, options + + +def node_destination(caller): + prototype = _get_menu_prototype(caller) + dest = prototype.get("dest") + + text = ["Set the prototype's |yDestination|n. This is usually only used for Exits."] + if dest: + text.append("Current destination is |y{dest}|n.".format(dest=dest)) + else: + text.append("No destination is set (default).") + text = "\n\n".join(text) + options = _wizard_options("destination", "home", "prototype_desc") + options.append({"key": "_default", + "goto": (_set_property, + dict(prop="dest", + processor=lambda s: s.strip(), + next_node="node_prototype_desc"))}) + return text, options + + +def node_prototype_desc(caller): + + prototype = _get_menu_prototype(caller) + text = ["The |wMeta-Description|n briefly describes the prototype for viewing in listings."] + desc = prototype.get("prototype_desc", None) + + if desc: + text.append("The current meta desc is:\n\"|w{desc}|n\"".format(desc=desc)) + else: + text.append("Description is currently unset.") + text = "\n\n".join(text) + options = _wizard_options("prototype_desc", "prototype_key", "prototype_tags") + options.append({"key": "_default", + "goto": (_set_property, + dict(prop='prototype_desc', + processor=lambda s: s.strip(), + next_node="node_prototype_tags"))}) + + return text, options + + +def node_prototype_tags(caller): + prototype = _get_menu_prototype(caller) + text = ["|wMeta-Tags|n can be used to classify and find prototypes. Tags are case-insensitive. " + "Separate multiple by tags by commas."] + tags = prototype.get('prototype_tags', []) + + if tags: + text.append("The current tags are:\n|w{tags}|n".format(tags=tags)) + else: + text.append("No tags are currently set.") + text = "\n\n".join(text) + options = _wizard_options("prototype_tags", "prototype_desc", "prototype_locks") + options.append({"key": "_default", + "goto": (_set_property, + dict(prop="prototype_tags", + processor=lambda s: [ + str(part.strip().lower()) for part in s.split(",")], + next_node="node_prototype_locks"))}) + return text, options + + +def node_prototype_locks(caller): + prototype = _get_menu_prototype(caller) + text = ["Set |wMeta-Locks|n on the prototype. There are two valid lock types: " + "'edit' (who can edit the prototype) and 'use' (who can apply the prototype)\n" + "(If you are unsure, leave as default.)"] + locks = prototype.get('prototype_locks', '') + if locks: + text.append("Current lock is |w'{lockstring}'|n".format(lockstring=locks)) + else: + text.append("Lock unset - if not changed the default lockstring will be set as\n" + " |w'use:all(); edit:id({dbref}) or perm(Admin)'|n".format(dbref=caller.id)) + text = "\n\n".join(text) + options = _wizard_options("prototype_locks", "prototype_tags", "index") + options.append({"key": "_default", + "goto": (_set_property, + dict(prop="prototype_locks", + processor=lambda s: s.strip().lower(), + next_node="node_index"))}) + return text, options + + +class OLCMenu(EvMenu): + """ + A custom EvMenu with a different formatting for the options. + + """ + def options_formatter(self, optionlist): + """ + Split the options into two blocks - olc options and normal options + + """ + olc_keys = ("index", "forward", "back", "previous", "next", "validate prototype") + olc_options = [] + other_options = [] + for key, desc in optionlist: + raw_key = strip_ansi(key) + if raw_key in olc_keys: + desc = " {}".format(desc) if desc else "" + olc_options.append("|lc{}|lt{}|le{}".format(raw_key, key, desc)) + else: + other_options.append((key, desc)) + + olc_options = " | ".join(olc_options) + " | " + "|wq|Wuit" if olc_options else "" + other_options = super(OLCMenu, self).options_formatter(other_options) + sep = "\n\n" if olc_options and other_options else "" + + return "{}{}{}".format(olc_options, sep, other_options) + + +def start_olc(caller, session=None, prototype=None): + """ + Start menu-driven olc system for prototypes. + + Args: + caller (Object or Account): The entity starting the menu. + session (Session, optional): The individual session to get data. + prototype (dict, optional): Given when editing an existing + prototype rather than creating a new one. + + """ + menudata = {"node_index": node_index, + "node_validate_prototype": node_validate_prototype, + "node_prototype_key": node_prototype_key, + "node_prototype": node_prototype, + "node_typeclass": node_typeclass, + "node_key": node_key, + "node_aliases": node_aliases, + "node_attrs": node_attrs, + "node_tags": node_tags, + "node_locks": node_locks, + "node_permissions": node_permissions, + "node_location": node_location, + "node_home": node_home, + "node_destination": node_destination, + "node_prototype_desc": node_prototype_desc, + "node_prototype_tags": node_prototype_tags, + "node_prototype_locks": node_prototype_locks, + } + OLCMenu(caller, menudata, startnode='node_index', session=session, olc_prototype=prototype) + diff --git a/evennia/prototypes/protfuncs.py b/evennia/prototypes/protfuncs.py new file mode 100644 index 0000000000..057f5f770f --- /dev/null +++ b/evennia/prototypes/protfuncs.py @@ -0,0 +1,78 @@ +""" +Protfuncs are function-strings embedded in a prototype and allows for a builder to create a +prototype with custom logics without having access to Python. The Protfunc is parsed using the +inlinefunc parser but is fired at the moment the spawning happens, using the creating object's +session as input. + +In the prototype dict, the protfunc is specified as a string inside the prototype, e.g.: + + { ... + + "key": "$funcname(arg1, arg2, ...)" + + ... } + +and multiple functions can be nested (no keyword args are supported). The result will be used as the +value for that prototype key for that individual spawn. + +Available protfuncs are callables in one of the modules of `settings.PROTOTYPEFUNC_MODULES`. They +are specified as functions + + def funcname (*args, **kwargs) + +where *args are the arguments given in the prototype, and **kwargs are inserted by Evennia: + + - session (Session): The Session of the entity spawning using this prototype. + - prototype_key (str): The currently spawning prototype-key. + - prototype (dict): The dict this protfunc is a part of. + +Any traceback raised by this function will be handled at the time of spawning and abort the spawn +before any object is created/updated. It must otherwise return the value to store for the specified +prototype key (this value must be possible to serialize in an Attribute). + +""" + +from django.conf import settings +from evennia.utils import inlinefuncs +from evennia.utils.utils import callables_from_module + + +_PROTOTYPEFUNCS = {} + +for mod in settings.PROTOTYPEFUNC_MODULES: + try: + callables = callables_from_module(mod) + if mod == __name__: + callables.pop("protfunc_parser") + _PROTOTYPEFUNCS.update(callables) + except ImportError: + pass + + +def protfunc_parser(value, available_functions=None, **kwargs): + """ + Parse a prototype value string for a protfunc and process it. + + Available protfuncs are specified as callables in one of the modules of + `settings.PROTOTYPEFUNC_MODULES`, or specified on the command line. + + Args: + value (string): The value to test for a parseable protfunc. + available_functions (dict, optional): Mapping of name:protfunction to use for this parsing. + + Kwargs: + any (any): Passed on to the inlinefunc. + + Returns: + any (any): A structure to replace the string on the prototype level. If this is a + callable or a (callable, (args,)) structure, it will be executed as if one had supplied + it to the prototype directly. + + """ + if not isinstance(value, basestring): + return value + available_functions = _PROTOTYPEFUNCS if available_functions is None else available_functions + return inlinefuncs.parse_inlinefunc(value, _available_funcs=available_functions, **kwargs) + + +# default protfuncs diff --git a/evennia/prototypes/prototypes.py b/evennia/prototypes/prototypes.py new file mode 100644 index 0000000000..60e194861b --- /dev/null +++ b/evennia/prototypes/prototypes.py @@ -0,0 +1,280 @@ +""" + +Handling storage of prototypes, both database-based ones (DBPrototypes) and those defined in modules +(Read-only prototypes). + +""" + +from django.conf import settings +from evennia.scripts.scripts import DefaultScript +from evennia.objects.models import ObjectDB +from evennia.utils.create import create_script +from evennia.utils.utils import all_from_module, make_iter, callables_from_module, is_iter +from evennia.locks.lockhandler import validate_lockstring, check_lockstring +from evennia.utils import logger + + +_MODULE_PROTOTYPE_MODULES = {} +_MODULE_PROTOTYPES = {} + + +class ValidationError(RuntimeError): + """ + Raised on prototype validation errors + """ + pass + + +# module-based prototypes + +for mod in settings.PROTOTYPE_MODULES: + # to remove a default prototype, override it with an empty dict. + # internally we store as (key, desc, locks, tags, prototype_dict) + prots = [(prototype_key, prot) for prototype_key, prot in all_from_module(mod).items() + if prot and isinstance(prot, dict)] + # assign module path to each prototype_key for easy reference + _MODULE_PROTOTYPE_MODULES.update({prototype_key: mod for prototype_key, _ in prots}) + # make sure the prototype contains all meta info + for prototype_key, prot in prots: + actual_prot_key = prot.get('prototype_key', prototype_key).lower() + prot.update({ + "prototype_key": actual_prot_key, + "prototype_desc": prot['prototype_desc'] if 'prototype_desc' in prot else mod, + "prototype_locks": (prot['prototype_locks'] + if 'prototype_locks' in prot else "use:all();edit:false()"), + "prototype_tags": list(set(make_iter(prot.get('prototype_tags', [])) + ["module"]))}) + _MODULE_PROTOTYPES[actual_prot_key] = prot + + +# Db-based prototypes + + +class DbPrototype(DefaultScript): + """ + This stores a single prototype, in an Attribute `prototype`. + """ + def at_script_creation(self): + self.key = "empty prototype" # prototype_key + self.desc = "A prototype" # prototype_desc + self.db.prototype = {} # actual prototype + + +# General prototype functions + +def check_permission(prototype_key, action, default=True): + """ + Helper function to check access to actions on given prototype. + + Args: + prototype_key (str): The prototype to affect. + action (str): One of "spawn" or "edit". + default (str): If action is unknown or prototype has no locks + + Returns: + passes (bool): If permission for action is granted or not. + + """ + if action == 'edit': + if prototype_key in _MODULE_PROTOTYPES: + mod = _MODULE_PROTOTYPE_MODULES.get(prototype_key, "N/A") + logger.log_err("{} is a read-only prototype " + "(defined as code in {}).".format(prototype_key, mod)) + return False + + prototype = search_prototype(key=prototype_key) + if not prototype: + logger.log_err("Prototype {} not found.".format(prototype_key)) + return False + + lockstring = prototype.get("prototype_locks") + + if lockstring: + return check_lockstring(None, lockstring, default=default, access_type=action) + return default + + +def create_prototype(**kwargs): + """ + Store a prototype persistently. + + Kwargs: + prototype_key (str): This is required for any storage. + All other kwargs are considered part of the new prototype dict. + + Returns: + prototype (dict or None): The prototype stored using the given kwargs, None if deleting. + + Raises: + prototypes.ValidationError: If prototype does not validate. + + Note: + No edit/spawn locks will be checked here - if this function is called the caller + is expected to have valid permissions. + + """ + + def _to_batchtuple(inp, *args): + "build tuple suitable for batch-creation" + if is_iter(inp): + # already a tuple/list, use as-is + return inp + return (inp, ) + args + + prototype_key = kwargs.get("prototype_key") + if not prototype_key: + raise ValidationError("Prototype requires a prototype_key") + + prototype_key = str(prototype_key).lower() + + # we can't edit a prototype defined in a module + if prototype_key in _MODULE_PROTOTYPES: + mod = _MODULE_PROTOTYPE_MODULES.get(prototype_key, "N/A") + raise PermissionError("{} is a read-only prototype " + "(defined as code in {}).".format(prototype_key, mod)) + + # want to create- or edit + prototype = kwargs + + # make sure meta properties are included with defaults + prototype['prototype_desc'] = prototype.get('prototype_desc', '') + locks = prototype.get('prototype_locks', "spawn:all();edit:perm(Admin)") + is_valid, err = validate_lockstring(locks) + if not is_valid: + raise ValidationError("Lock error: {}".format(err)) + prototype["prototype_locks"] = locks + prototype["prototype_tags"] = [ + _to_batchtuple(tag, "db_prototype") + for tag in make_iter(prototype.get("prototype_tags", []))] + + stored_prototype = DbPrototype.objects.filter(db_key=prototype_key) + + if stored_prototype: + # edit existing prototype + stored_prototype = stored_prototype[0] + + stored_prototype.desc = prototype['prototype_desc'] + stored_prototype.tags.batch_add(*prototype['prototype_tags']) + stored_prototype.locks.add(prototype['prototype_locks']) + stored_prototype.attributes.add('prototype', prototype) + else: + # create a new prototype + stored_prototype = create_script( + DbPrototype, key=prototype_key, desc=prototype['prototype_desc'], persistent=True, + locks=locks, tags=prototype['prototype_tags'], attributes=[("prototype", prototype)]) + return stored_prototype + + +def delete_prototype(key, caller=None): + """ + Delete a stored prototype + + Args: + key (str): The persistent prototype to delete. + caller (Account or Object, optionsl): Caller aiming to delete a prototype. + Note that no locks will be checked if`caller` is not passed. + Returns: + success (bool): If deletion worked or not. + Raises: + PermissionError: If 'edit' lock was not passed or deletion failed for some other reason. + + """ + if prototype_key in _MODULE_PROTOTYPES: + mod = _MODULE_PROTOTYPE_MODULES.get(prototype_key, "N/A") + raise PermissionError("{} is a read-only prototype " + "(defined as code in {}).".format(prototype_key, mod)) + + stored_prototype = DbPrototype.objects.filter(db_key=prototype_key) + + if not stored_prototype: + raise PermissionError("Prototype {} was not found.".format(prototype_key)) + if caller: + if not stored_prototype.access(caller, 'edit'): + raise PermissionError("{} does not have permission to " + "delete prototype {}.".format(caller, prototype_key)) + stored_prototype.delete() + return True + + +def search_prototype(key=None, tags=None): + """ + Find prototypes based on key and/or tags, or all prototypes. + + Kwargs: + key (str): An exact or partial key to query for. + tags (str or list): Tag key or keys to query for. These + will always be applied with the 'db_protototype' + tag category. + + Return: + matches (list): All found prototype dicts. If no keys + or tags are given, all available prototypes will be returned. + + Note: + The available prototypes is a combination of those supplied in + PROTOTYPE_MODULES and those stored in the database. Note that if + tags are given and the prototype has no tags defined, it will not + be found as a match. + + """ + # search module prototypes + + mod_matches = {} + if tags: + # use tags to limit selection + tagset = set(tags) + mod_matches = {prototype_key: prototype + for prototype_key, prototype in _MODULE_PROTOTYPES.items() + if tagset.intersection(prototype.get("prototype_tags", []))} + else: + mod_matches = _MODULE_PROTOTYPES + if key: + if key in mod_matches: + # exact match + module_prototypes = [mod_matches[key]] + else: + # fuzzy matching + module_prototypes = [prototype for prototype_key, prototype in mod_matches.items() + if key in prototype_key] + else: + module_prototypes = [match for match in mod_matches.values()] + + # search db-stored prototypes + + if tags: + # exact match on tag(s) + tags = make_iter(tags) + tag_categories = ["db_prototype" for _ in tags] + db_matches = DbPrototype.objects.get_by_tag(tags, tag_categories) + else: + db_matches = DbPrototype.objects.all() + if key: + # exact or partial match on key + db_matches = db_matches.filter(db_key=key) or db_matches.filter(db_key__icontains=key) + # return prototype + db_prototypes = [dict(dbprot.attributes.get("prototype", {})) for dbprot in db_matches] + + matches = db_prototypes + module_prototypes + nmatches = len(matches) + if nmatches > 1 and key: + key = key.lower() + # avoid duplicates if an exact match exist between the two types + filter_matches = [mta for mta in matches + if mta.get('prototype_key') and mta['prototype_key'] == key] + if filter_matches and len(filter_matches) < nmatches: + matches = filter_matches + + return matches + + +def search_objects_with_prototype(prototype_key): + """ + Retrieve all object instances created by a given prototype. + + Args: + prototype_key (str): The exact (and unique) prototype identifier to query for. + + Returns: + matches (Queryset): All matching objects spawned from this prototype. + + """ + return ObjectDB.objects.get_by_tag(key=prototype_key, category=_PROTOTYPE_TAG_CATEGORY) diff --git a/evennia/prototypes/spawner.py b/evennia/prototypes/spawner.py new file mode 100644 index 0000000000..062e15ee92 --- /dev/null +++ b/evennia/prototypes/spawner.py @@ -0,0 +1,600 @@ +""" +Spawner + +The spawner takes input files containing object definitions in +dictionary forms. These use a prototype architecture to define +unique objects without having to make a Typeclass for each. + +The main function is `spawn(*prototype)`, where the `prototype` +is a dictionary like this: + +```python +GOBLIN = { + "typeclass": "types.objects.Monster", + "key": "goblin grunt", + "health": lambda: randint(20,30), + "resists": ["cold", "poison"], + "attacks": ["fists"], + "weaknesses": ["fire", "light"] + "tags": ["mob", "evil", ('greenskin','mob')] + "attrs": [("weapon", "sword")] + } +``` + +Possible keywords are: + prototype_key (str): name of this prototype. This is used when storing prototypes and should + be unique. This should always be defined but for prototypes defined in modules, the + variable holding the prototype dict will become the prototype_key if it's not explicitly + given. + prototype_desc (str, optional): describes prototype in listings + prototype_locks (str, optional): locks for restricting access to this prototype. Locktypes + supported are 'edit' and 'use'. + prototype_tags(list, optional): List of tags or tuples (tag, category) used to group prototype + in listings + + parent (str, tuple or callable, optional): name (prototype_key) of eventual parent prototype, or + a list of parents, for multiple left-to-right inheritance. + prototype: Deprecated. Same meaning as 'parent'. + typeclass (str or callable, optional): if not set, will use typeclass of parent prototype or use + `settings.BASE_OBJECT_TYPECLASS` + key (str or callable, optional): the name of the spawned object. If not given this will set to a + random hash + location (obj, str or callable, optional): location of the object - a valid object or #dbref + home (obj, str or callable, optional): valid object or #dbref + destination (obj, str or callable, optional): only valid for exits (object or #dbref) + + permissions (str, list or callable, optional): which permissions for spawned object to have + locks (str or callable, optional): lock-string for the spawned object + aliases (str, list or callable, optional): Aliases for the spawned object + exec (str or callable, optional): this is a string of python code to execute or a list of such + codes. This can be used e.g. to trigger custom handlers on the object. The execution + namespace contains 'evennia' for the library and 'obj'. All default spawn commands limit + this functionality to Developer/superusers. Usually it's better to use callables or + prototypefuncs instead of this. + tags (str, tuple, list or callable, optional): string or list of strings or tuples + `(tagstr, category)`. Plain strings will be result in tags with no category (default tags). + attrs (tuple, list or callable, optional): tuple or list of tuples of Attributes to add. This + form allows more complex Attributes to be set. Tuples at least specify `(key, value)` + but can also specify up to `(key, value, category, lockstring)`. If you want to specify a + lockstring but not a category, set the category to `None`. + ndb_ (any): value of a nattribute (ndb_ is stripped) + other (any): any other name is interpreted as the key of an Attribute with + its value. Such Attributes have no categories. + +Each value can also be a callable that takes no arguments. It should +return the value to enter into the field and will be called every time +the prototype is used to spawn an object. Note, if you want to store +a callable in an Attribute, embed it in a tuple to the `args` keyword. + +By specifying the "prototype" key, the prototype becomes a child of +that prototype, inheritng all prototype slots it does not explicitly +define itself, while overloading those that it does specify. + +```python +import random + + +GOBLIN_WIZARD = { + "parent": GOBLIN, + "key": "goblin wizard", + "spells": ["fire ball", "lighting bolt"] + } + +GOBLIN_ARCHER = { + "parent": GOBLIN, + "key": "goblin archer", + "attack_skill": (random, (5, 10))" + "attacks": ["short bow"] +} +``` + +One can also have multiple prototypes. These are inherited from the +left, with the ones further to the right taking precedence. + +```python +ARCHWIZARD = { + "attack": ["archwizard staff", "eye of doom"] + +GOBLIN_ARCHWIZARD = { + "key" : "goblin archwizard" + "parent": (GOBLIN_WIZARD, ARCHWIZARD), +} +``` + +The *goblin archwizard* will have some different attacks, but will +otherwise have the same spells as a *goblin wizard* who in turn shares +many traits with a normal *goblin*. + + +Storage mechanism: + +This sets up a central storage for prototypes. The idea is to make these +available in a repository for buildiers to use. Each prototype is stored +in a Script so that it can be tagged for quick sorting/finding and locked for limiting +access. + +This system also takes into consideration prototypes defined and stored in modules. +Such prototypes are considered 'read-only' to the system and can only be modified +in code. To replace a default prototype, add the same-name prototype in a +custom module read later in the settings.PROTOTYPE_MODULES list. To remove a default +prototype, override its name with an empty dict. + + +""" +from __future__ import print_function + +import copy +import hashlib +import time +from ast import literal_eval +from django.conf import settings +from random import randint +import evennia +from evennia.objects.models import ObjectDB +from evennia.utils.utils import ( + make_iter, dbid_to_obj, + is_iter, crop, get_all_typeclasses) + +from evennia.utils.evtable import EvTable + + +_CREATE_OBJECT_KWARGS = ("key", "location", "home", "destination") +_PROTOTYPE_META_NAMES = ("prototype_key", "prototype_desc", "prototype_tags", "prototype_locks") +_NON_CREATE_KWARGS = _CREATE_OBJECT_KWARGS + _PROTOTYPE_META_NAMES +_MENU_CROP_WIDTH = 15 +_PROTOTYPE_TAG_CATEGORY = "spawned_by_prototype" + +_MENU_ATTR_LITERAL_EVAL_ERROR = ( + "|rCritical Python syntax error in your value. Only primitive Python structures are allowed.\n" + "You also need to use correct Python syntax. Remember especially to put quotes around all " + "strings inside lists and dicts.|n") + + +# Helper functions + +def _to_obj(value, force=True): + return dbid_to_obj(value, ObjectDB) + + +def _to_obj_or_any(value): + obj = dbid_to_obj(value, ObjectDB) + return obj if obj is not None else value + + +def validate_spawn_value(value, validator=None): + """ + Analyze the value and produce a value for use at the point of spawning. + + Args: + value (any): This can be: + callable - will be called as callable() + (callable, (args,)) - will be called as callable(*args) + other - will be assigned depending on the variable type + validator (callable, optional): If given, this will be called with the value to + check and guarantee the outcome is of a given type. + + Returns: + any (any): The (potentially pre-processed value to use for this prototype key) + + """ + value = protfunc_parser(value) + validator = validator if validator else lambda o: o + if callable(value): + return validator(value()) + elif value and is_iter(value) and callable(value[0]): + # a structure (callable, (args, )) + args = value[1:] + return validator(value[0](*make_iter(args))) + else: + return validator(value) + +# Spawner mechanism + + +def validate_prototype(prototype, protkey=None, protparents=None, _visited=None): + """ + Run validation on a prototype, checking for inifinite regress. + + Args: + prototype (dict): Prototype to validate. + protkey (str, optional): The name of the prototype definition. If not given, the prototype + dict needs to have the `prototype_key` field set. + protpartents (dict, optional): The available prototype parent library. If + note given this will be determined from settings/database. + _visited (list, optional): This is an internal work array and should not be set manually. + Raises: + RuntimeError: If prototype has invalid structure. + + """ + if not protparents: + protparents = get_protparent_dict() + if _visited is None: + _visited = [] + + protkey = protkey and protkey.lower() or prototype.get('prototype_key', None) + + assert isinstance(prototype, dict) + + if id(prototype) in _visited: + raise RuntimeError("%s has infinite nesting of prototypes." % protkey or prototype) + + _visited.append(id(prototype)) + protstrings = prototype.get("prototype") + + if protstrings: + for protstring in make_iter(protstrings): + protstring = protstring.lower() + if protkey is not None and protstring == protkey: + raise RuntimeError("%s tries to prototype itself." % protkey or prototype) + protparent = protparents.get(protstring) + if not protparent: + raise RuntimeError( + "%s's prototype '%s' was not found." % (protkey or prototype, protstring)) + validate_prototype(protparent, protstring, protparents, _visited) + + +def _get_prototype(dic, prot, protparents): + """ + Recursively traverse a prototype dictionary, including multiple + inheritance. Use validate_prototype before this, we don't check + for infinite recursion here. + + """ + if "prototype" in dic: + # move backwards through the inheritance + for prototype in make_iter(dic["prototype"]): + # Build the prot dictionary in reverse order, overloading + new_prot = _get_prototype(protparents.get(prototype.lower(), {}), prot, protparents) + prot.update(new_prot) + prot.update(dic) + prot.pop("prototype", None) # we don't need this anymore + return prot + + +def prototype_diff_from_object(prototype, obj): + """ + Get a simple diff for a prototype compared to an object which may or may not already have a + prototype (or has one but changed locally). For more complex migratations a manual diff may be + needed. + + Args: + prototype (dict): Prototype. + obj (Object): Object to + + Returns: + diff (dict): Mapping for every prototype key: {"keyname": "REMOVE|UPDATE|KEEP", ...} + + """ + prot1 = prototype + prot2 = prototype_from_object(obj) + + diff = {} + for key, value in prot1.items(): + diff[key] = "KEEP" + if key in prot2: + if callable(prot2[key]) or value != prot2[key]: + diff[key] = "UPDATE" + elif key not in prot2: + diff[key] = "REMOVE" + + return diff + + +def batch_update_objects_with_prototype(prototype, diff=None, objects=None): + """ + Update existing objects with the latest version of the prototype. + + Args: + prototype (str or dict): Either the `prototype_key` to use or the + prototype dict itself. + diff (dict, optional): This a diff structure that describes how to update the protototype. + If not given this will be constructed from the first object found. + objects (list, optional): List of objects to update. If not given, query for these + objects using the prototype's `prototype_key`. + Returns: + changed (int): The number of objects that had changes applied to them. + + """ + prototype_key = prototype if isinstance(prototype, basestring) else prototype['prototype_key'] + prototype_obj = search_db_prototype(prototype_key, return_queryset=True) + prototype_obj = prototype_obj[0] if prototype_obj else None + new_prototype = prototype_obj.db.prototype + objs = ObjectDB.objects.get_by_tag(prototype_key, category=_PROTOTYPE_TAG_CATEGORY) + + if not objs: + return 0 + + if not diff: + diff = prototype_diff_from_object(new_prototype, objs[0]) + + changed = 0 + for obj in objs: + do_save = False + for key, directive in diff.items(): + val = new_prototype[key] + if directive in ('UPDATE', 'REPLACE'): + do_save = True + if key == 'key': + obj.db_key = validate_spawn_value(val, str) + elif key == 'typeclass': + obj.db_typeclass_path = validate_spawn_value(val, str) + elif key == 'location': + obj.db_location = validate_spawn_value(val, _to_obj) + elif key == 'home': + obj.db_home = validate_spawn_value(val, _to_obj) + elif key == 'destination': + obj.db_destination = validate_spawn_value(val, _to_obj) + elif key == 'locks': + if directive == 'REPLACE': + obj.locks.clear() + obj.locks.add(validate_spawn_value(val, str)) + elif key == 'permissions': + if directive == 'REPLACE': + obj.permissions.clear() + obj.permissions.batch_add(validate_spawn_value(val, make_iter)) + elif key == 'aliases': + if directive == 'REPLACE': + obj.aliases.clear() + obj.aliases.batch_add(validate_spawn_value(val, make_iter)) + elif key == 'tags': + if directive == 'REPLACE': + obj.tags.clear() + obj.tags.batch_add(validate_spawn_value(val, make_iter)) + elif key == 'attrs': + if directive == 'REPLACE': + obj.attributes.clear() + obj.attributes.batch_add(validate_spawn_value(val, make_iter)) + elif key == 'exec': + # we don't auto-rerun exec statements, it would be huge security risk! + pass + else: + obj.attributes.add(key, validate_spawn_value(val, _to_obj)) + elif directive == 'REMOVE': + do_save = True + if key == 'key': + obj.db_key = '' + elif key == 'typeclass': + # fall back to default + obj.db_typeclass_path = settings.BASE_OBJECT_TYPECLASS + elif key == 'location': + obj.db_location = None + elif key == 'home': + obj.db_home = None + elif key == 'destination': + obj.db_destination = None + elif key == 'locks': + obj.locks.clear() + elif key == 'permissions': + obj.permissions.clear() + elif key == 'aliases': + obj.aliases.clear() + elif key == 'tags': + obj.tags.clear() + elif key == 'attrs': + obj.attributes.clear() + elif key == 'exec': + # we don't auto-rerun exec statements, it would be huge security risk! + pass + else: + obj.attributes.remove(key) + if do_save: + changed += 1 + obj.save() + + return changed + + +def _batch_create_object(*objparams): + """ + This is a cut-down version of the create_object() function, + optimized for speed. It does NOT check and convert various input + so make sure the spawned Typeclass works before using this! + + Args: + objsparams (tuple): Each paremter tuple will create one object instance using the parameters within. + The parameters should be given in the following order: + - `create_kwargs` (dict): For use as new_obj = `ObjectDB(**create_kwargs)`. + - `permissions` (str): Permission string used with `new_obj.batch_add(permission)`. + - `lockstring` (str): Lockstring used with `new_obj.locks.add(lockstring)`. + - `aliases` (list): A list of alias strings for + adding with `new_object.aliases.batch_add(*aliases)`. + - `nattributes` (list): list of tuples `(key, value)` to be loop-added to + add with `new_obj.nattributes.add(*tuple)`. + - `attributes` (list): list of tuples `(key, value[,category[,lockstring]])` for + adding with `new_obj.attributes.batch_add(*attributes)`. + - `tags` (list): list of tuples `(key, category)` for adding + with `new_obj.tags.batch_add(*tags)`. + - `execs` (list): Code strings to execute together with the creation + of each object. They will be executed with `evennia` and `obj` + (the newly created object) available in the namespace. Execution + will happend after all other properties have been assigned and + is intended for calling custom handlers etc. + + Returns: + objects (list): A list of created objects + + Notes: + The `exec` list will execute arbitrary python code so don't allow this to be available to + unprivileged users! + + """ + + # bulk create all objects in one go + + # unfortunately this doesn't work since bulk_create doesn't creates pks; + # the result would be duplicate objects at the next stage, so we comment + # it out for now: + # dbobjs = _ObjectDB.objects.bulk_create(dbobjs) + + dbobjs = [ObjectDB(**objparam[0]) for objparam in objparams] + objs = [] + for iobj, obj in enumerate(dbobjs): + # call all setup hooks on each object + objparam = objparams[iobj] + # setup + obj._createdict = {"permissions": make_iter(objparam[1]), + "locks": objparam[2], + "aliases": make_iter(objparam[3]), + "nattributes": objparam[4], + "attributes": objparam[5], + "tags": make_iter(objparam[6])} + # this triggers all hooks + obj.save() + # run eventual extra code + for code in objparam[7]: + if code: + exec(code, {}, {"evennia": evennia, "obj": obj}) + objs.append(obj) + return objs + + +def spawn(*prototypes, **kwargs): + """ + Spawn a number of prototyped objects. + + Args: + prototypes (dict): Each argument should be a prototype + dictionary. + Kwargs: + prototype_modules (str or list): A python-path to a prototype + module, or a list of such paths. These will be used to build + the global protparents dictionary accessible by the input + prototypes. If not given, it will instead look for modules + defined by settings.PROTOTYPE_MODULES. + prototype_parents (dict): A dictionary holding a custom + prototype-parent dictionary. Will overload same-named + prototypes from prototype_modules. + return_prototypes (bool): Only return a list of the + prototype-parents (no object creation happens) + + Returns: + object (Object): Spawned object. + + """ + # get available protparents + protparents = get_protparent_dict() + + # overload module's protparents with specifically given protparents + protparents.update(kwargs.get("prototype_parents", {})) + for key, prototype in protparents.items(): + validate_prototype(prototype, key.lower(), protparents) + + if "return_prototypes" in kwargs: + # only return the parents + return copy.deepcopy(protparents) + + objsparams = [] + for prototype in prototypes: + + validate_prototype(prototype, None, protparents) + prot = _get_prototype(prototype, {}, protparents) + if not prot: + continue + + # extract the keyword args we need to create the object itself. If we get a callable, + # call that to get the value (don't catch errors) + create_kwargs = {} + # we must always add a key, so if not given we use a shortened md5 hash. There is a (small) + # chance this is not unique but it should usually not be a problem. + val = prot.pop("key", "Spawned-{}".format( + hashlib.md5(str(time.time())).hexdigest()[:6])) + create_kwargs["db_key"] = validate_spawn_value(val, str) + + val = prot.pop("location", None) + create_kwargs["db_location"] = validate_spawn_value(val, _to_obj) + + val = prot.pop("home", settings.DEFAULT_HOME) + create_kwargs["db_home"] = validate_spawn_value(val, _to_obj) + + val = prot.pop("destination", None) + create_kwargs["db_destination"] = validate_spawn_value(val, _to_obj) + + val = prot.pop("typeclass", settings.BASE_OBJECT_TYPECLASS) + create_kwargs["db_typeclass_path"] = validate_spawn_value(val, str) + + # extract calls to handlers + val = prot.pop("permissions", []) + permission_string = validate_spawn_value(val, make_iter) + val = prot.pop("locks", "") + lock_string = validate_spawn_value(val, str) + val = prot.pop("aliases", []) + alias_string = validate_spawn_value(val, make_iter) + + val = prot.pop("tags", []) + tags = validate_spawn_value(val, make_iter) + + prototype_key = prototype.get('prototype_key', None) + if prototype_key: + # we make sure to add a tag identifying which prototype created this object + tags.append((prototype_key, _PROTOTYPE_TAG_CATEGORY)) + + val = prot.pop("exec", "") + execs = validate_spawn_value(val, make_iter) + + # extract ndb assignments + nattribute = dict((key.split("_", 1)[1], validate_spawn_value(val, _to_obj)) + for key, val in prot.items() if key.startswith("ndb_")) + + # the rest are attributes + val = prot.pop("attrs", []) + attributes = validate_spawn_value(val, list) + + simple_attributes = [] + for key, value in ((key, value) for key, value in prot.items() + if not (key.startswith("ndb_"))): + if is_iter(value) and len(value) > 1: + # (value, category) + simple_attributes.append((key, + validate_spawn_value(value[0], _to_obj_or_any), + validate_spawn_value(value[1], str))) + else: + simple_attributes.append((key, + validate_spawn_value(value, _to_obj_or_any))) + + attributes = attributes + simple_attributes + attributes = [tup for tup in attributes if not tup[0] in _NON_CREATE_KWARGS] + + # pack for call into _batch_create_object + objsparams.append((create_kwargs, permission_string, lock_string, + alias_string, nattributes, attributes, tags, execs)) + + return _batch_create_object(*objsparams) + + +# Testing + +if __name__ == "__main__": + protparents = { + "NOBODY": {}, + # "INFINITE" : { + # "prototype":"INFINITE" + # }, + "GOBLIN": { + "key": "goblin grunt", + "health": lambda: randint(20, 30), + "resists": ["cold", "poison"], + "attacks": ["fists"], + "weaknesses": ["fire", "light"] + }, + "GOBLIN_WIZARD": { + "prototype": "GOBLIN", + "key": "goblin wizard", + "spells": ["fire ball", "lighting bolt"] + }, + "GOBLIN_ARCHER": { + "prototype": "GOBLIN", + "key": "goblin archer", + "attacks": ["short bow"] + }, + "ARCHWIZARD": { + "attacks": ["archwizard staff"], + }, + "GOBLIN_ARCHWIZARD": { + "key": "goblin archwizard", + "prototype": ("GOBLIN_WIZARD", "ARCHWIZARD") + } + } + # test + print([o.key for o in spawn(protparents["GOBLIN"], + protparents["GOBLIN_ARCHWIZARD"], + prototype_parents=protparents)]) diff --git a/evennia/prototypes/utils.py b/evennia/prototypes/utils.py new file mode 100644 index 0000000000..74eaef169f --- /dev/null +++ b/evennia/prototypes/utils.py @@ -0,0 +1,150 @@ +""" + +Prototype utilities + +""" + + +class PermissionError(RuntimeError): + pass + + + + + +def get_protparent_dict(): + """ + Get prototype parents. + + Returns: + parent_dict (dict): A mapping {prototype_key: prototype} for all available prototypes. + + """ + return {prototype['prototype_key']: prototype for prototype in search_prototype()} + + +def list_prototypes(caller, key=None, tags=None, show_non_use=False, show_non_edit=True): + """ + Collate a list of found prototypes based on search criteria and access. + + Args: + caller (Account or Object): The object requesting the list. + key (str, optional): Exact or partial prototype key to query for. + tags (str or list, optional): Tag key or keys to query for. + show_non_use (bool, optional): Show also prototypes the caller may not use. + show_non_edit (bool, optional): Show also prototypes the caller may not edit. + Returns: + table (EvTable or None): An EvTable representation of the prototypes. None + if no prototypes were found. + + """ + # this allows us to pass lists of empty strings + tags = [tag for tag in make_iter(tags) if tag] + + # get prototypes for readonly and db-based prototypes + prototypes = search_prototype(key, tags) + + # get use-permissions of readonly attributes (edit is always False) + display_tuples = [] + for prototype in sorted(prototypes, key=lambda d: d.get('prototype_key', '')): + lock_use = caller.locks.check_lockstring( + caller, prototype.get('prototype_locks', ''), access_type='use') + if not show_non_use and not lock_use: + continue + if prototype.get('prototype_key', '') in _MODULE_PROTOTYPES: + lock_edit = False + else: + lock_edit = caller.locks.check_lockstring( + caller, prototype.get('prototype_locks', ''), access_type='edit') + if not show_non_edit and not lock_edit: + continue + ptags = [] + for ptag in prototype.get('prototype_tags', []): + if is_iter(ptag): + if len(ptag) > 1: + ptags.append("{} (category: {}".format(ptag[0], ptag[1])) + else: + ptags.append(ptag[0]) + else: + ptags.append(str(ptag)) + + display_tuples.append( + (prototype.get('prototype_key', ''), + prototype.get('prototype_desc', ''), + "{}/{}".format('Y' if lock_use else 'N', 'Y' if lock_edit else 'N'), + ",".join(ptags))) + + if not display_tuples: + return None + + table = [] + width = 78 + for i in range(len(display_tuples[0])): + table.append([str(display_tuple[i]) for display_tuple in display_tuples]) + table = EvTable("Key", "Desc", "Use/Edit", "Tags", table=table, crop=True, width=width) + table.reformat_column(0, width=22) + table.reformat_column(1, width=29) + table.reformat_column(2, width=11, align='c') + table.reformat_column(3, width=16) + return table + + +def prototype_to_str(prototype): + """ + Format a prototype to a nice string representation. + + Args: + prototype (dict): The prototype. + """ + + header = ( + "|cprototype key:|n {}, |ctags:|n {}, |clocks:|n {} \n" + "|cdesc:|n {} \n|cprototype:|n ".format( + prototype['prototype_key'], + ", ".join(prototype['prototype_tags']), + prototype['prototype_locks'], + prototype['prototype_desc'])) + proto = ("{{\n {} \n}}".format( + "\n ".join( + "{!r}: {!r},".format(key, value) for key, value in + sorted(prototype.items()) if key not in _PROTOTYPE_META_NAMES)).rstrip(",")) + return header + proto + + +def prototype_from_object(obj): + """ + Guess a minimal prototype from an existing object. + + Args: + obj (Object): An object to analyze. + + Returns: + prototype (dict): A prototype estimating the current state of the object. + + """ + # first, check if this object already has a prototype + + prot = obj.tags.get(category=_PROTOTYPE_TAG_CATEGORY, return_list=True) + prot = search_prototype(prot) + if not prot or len(prot) > 1: + # no unambiguous prototype found - build new prototype + prot = {} + prot['prototype_key'] = "From-Object-{}-{}".format( + obj.key, hashlib.md5(str(time.time())).hexdigest()[:6]) + prot['prototype_desc'] = "Built from {}".format(str(obj)) + prot['prototype_locks'] = "use:all();edit:all()" + + prot['key'] = obj.db_key or hashlib.md5(str(time.time())).hexdigest()[:6] + prot['location'] = obj.db_location + prot['home'] = obj.db_home + prot['destination'] = obj.db_destination + prot['typeclass'] = obj.db_typeclass_path + prot['locks'] = obj.locks.all() + prot['permissions'] = obj.permissions.get() + prot['aliases'] = obj.aliases.get() + prot['tags'] = [(tag.key, tag.category, tag.data) + for tag in obj.tags.get(return_tagobj=True, return_list=True)] + prot['attrs'] = [(attr.key, attr.value, attr.category, attr.locks) + for attr in obj.attributes.get(return_obj=True, return_list=True)] + + return prot diff --git a/evennia/utils/spawner.py b/evennia/utils/spawner.py deleted file mode 100644 index 3c269ca742..0000000000 --- a/evennia/utils/spawner.py +++ /dev/null @@ -1,1752 +0,0 @@ -""" -Spawner - -The spawner takes input files containing object definitions in -dictionary forms. These use a prototype architecture to define -unique objects without having to make a Typeclass for each. - -The main function is `spawn(*prototype)`, where the `prototype` -is a dictionary like this: - -```python -GOBLIN = { - "typeclass": "types.objects.Monster", - "key": "goblin grunt", - "health": lambda: randint(20,30), - "resists": ["cold", "poison"], - "attacks": ["fists"], - "weaknesses": ["fire", "light"] - "tags": ["mob", "evil", ('greenskin','mob')] - "args": [("weapon", "sword")] - } -``` - -Possible keywords are: - prototype_key (str): name of this prototype. This is used when storing prototypes and should - be unique. This should always be defined but for prototypes defined in modules, the - variable holding the prototype dict will become the prototype_key if it's not explicitly - given. - prototype_desc (str, optional): describes prototype in listings - prototype_locks (str, optional): locks for restricting access to this prototype. Locktypes - supported are 'edit' and 'use'. - prototype_tags(list, optional): List of tags or tuples (tag, category) used to group prototype - in listings - - prototype (str or callable, optional): bame (prototype_key) of eventual parent prototype - typeclass (str or callable, optional): if not set, will use typeclass of parent prototype or use - `settings.BASE_OBJECT_TYPECLASS` - key (str or callable, optional): the name of the spawned object. If not given this will set to a - random hash - location (obj, str or callable, optional): location of the object - a valid object or #dbref - home (obj, str or callable, optional): valid object or #dbref - destination (obj, str or callable, optional): only valid for exits (object or #dbref) - - permissions (str, list or callable, optional): which permissions for spawned object to have - locks (str or callable, optional): lock-string for the spawned object - aliases (str, list or callable, optional): Aliases for the spawned object - exec (str or callable, optional): this is a string of python code to execute or a list of such - codes. This can be used e.g. to trigger custom handlers on the object. The execution - namespace contains 'evennia' for the library and 'obj'. All default spawn commands limit - this functionality to Developer/superusers. Usually it's better to use callables or - prototypefuncs instead of this. - tags (str, tuple, list or callable, optional): string or list of strings or tuples - `(tagstr, category)`. Plain strings will be result in tags with no category (default tags). - attrs (tuple, list or callable, optional): tuple or list of tuples of Attributes to add. This - form allows more complex Attributes to be set. Tuples at least specify `(key, value)` - but can also specify up to `(key, value, category, lockstring)`. If you want to specify a - lockstring but not a category, set the category to `None`. - ndb_ (any): value of a nattribute (ndb_ is stripped) - other (any): any other name is interpreted as the key of an Attribute with - its value. Such Attributes have no categories. - -Each value can also be a callable that takes no arguments. It should -return the value to enter into the field and will be called every time -the prototype is used to spawn an object. Note, if you want to store -a callable in an Attribute, embed it in a tuple to the `args` keyword. - -By specifying the "prototype" key, the prototype becomes a child of -that prototype, inheritng all prototype slots it does not explicitly -define itself, while overloading those that it does specify. - -```python -import random - - -GOBLIN_WIZARD = { - "prototype": GOBLIN, - "key": "goblin wizard", - "spells": ["fire ball", "lighting bolt"] - } - -GOBLIN_ARCHER = { - "prototype": GOBLIN, - "key": "goblin archer", - "attack_skill": (random, (5, 10))" - "attacks": ["short bow"] -} -``` - -One can also have multiple prototypes. These are inherited from the -left, with the ones further to the right taking precedence. - -```python -ARCHWIZARD = { - "attack": ["archwizard staff", "eye of doom"] - -GOBLIN_ARCHWIZARD = { - "key" : "goblin archwizard" - "prototype": (GOBLIN_WIZARD, ARCHWIZARD), -} -``` - -The *goblin archwizard* will have some different attacks, but will -otherwise have the same spells as a *goblin wizard* who in turn shares -many traits with a normal *goblin*. - - -Storage mechanism: - -This sets up a central storage for prototypes. The idea is to make these -available in a repository for buildiers to use. Each prototype is stored -in a Script so that it can be tagged for quick sorting/finding and locked for limiting -access. - -This system also takes into consideration prototypes defined and stored in modules. -Such prototypes are considered 'read-only' to the system and can only be modified -in code. To replace a default prototype, add the same-name prototype in a -custom module read later in the settings.PROTOTYPE_MODULES list. To remove a default -prototype, override its name with an empty dict. - - -""" -from __future__ import print_function - -import copy -import hashlib -import time -from ast import literal_eval -from django.conf import settings -from random import randint -import evennia -from evennia.objects.models import ObjectDB -from evennia.utils.utils import ( - make_iter, all_from_module, callables_from_module, dbid_to_obj, - is_iter, crop, get_all_typeclasses) -from evennia.utils import inlinefuncs - -from evennia.scripts.scripts import DefaultScript -from evennia.utils.create import create_script -from evennia.utils.evtable import EvTable -from evennia.utils.evmenu import EvMenu, list_node -from evennia.utils.ansi import strip_ansi - - -_CREATE_OBJECT_KWARGS = ("key", "location", "home", "destination") -_PROTOTYPE_META_NAMES = ("prototype_key", "prototype_desc", "prototype_tags", "prototype_locks") -_NON_CREATE_KWARGS = _CREATE_OBJECT_KWARGS + _PROTOTYPE_META_NAMES -_MODULE_PROTOTYPES = {} -_MODULE_PROTOTYPE_MODULES = {} -_PROTOTYPEFUNCS = {} -_MENU_CROP_WIDTH = 15 -_PROTOTYPE_TAG_CATEGORY = "spawned_by_prototype" - -_MENU_ATTR_LITERAL_EVAL_ERROR = ( - "|rCritical Python syntax error in your value. Only primitive Python structures are allowed.\n" - "You also need to use correct Python syntax. Remember especially to put quotes around all " - "strings inside lists and dicts.|n") - - -class PermissionError(RuntimeError): - pass - - -# load resources - - -for mod in settings.PROTOTYPE_MODULES: - # to remove a default prototype, override it with an empty dict. - # internally we store as (key, desc, locks, tags, prototype_dict) - prots = [(prototype_key, prot) for prototype_key, prot in all_from_module(mod).items() - if prot and isinstance(prot, dict)] - # assign module path to each prototype_key for easy reference - _MODULE_PROTOTYPE_MODULES.update({prototype_key: mod for prototype_key, _ in prots}) - # make sure the prototype contains all meta info - for prototype_key, prot in prots: - actual_prot_key = prot.get('prototype_key', prototype_key).lower() - prot.update({ - "prototype_key": actual_prot_key, - "prototype_desc": prot['prototype_desc'] if 'prototype_desc' in prot else mod, - "prototype_locks": (prot['prototype_locks'] - if 'prototype_locks' in prot else "use:all();edit:false()"), - "prototype_tags": list(set(make_iter(prot.get('prototype_tags', [])) + ["module"]))}) - _MODULE_PROTOTYPES[actual_prot_key] = prot - - -for mod in settings.PROTOTYPEFUNC_MODULES: - try: - _PROTOTYPEFUNCS.update(callables_from_module(mod)) - except ImportError: - pass - - -# Helper functions - - -def protfunc_parser(value, available_functions=None, **kwargs): - """ - This is intended to be used by the in-game olc mechanism. It will parse the prototype - value for function tokens like `$protfunc(arg, arg, ...)`. These functions behave all the - parameters of `inlinefuncs` but they are *not* passed a Session since this is not guaranteed to - be available at the time of spawning. They may also return other structures than strings. - - Available protfuncs are specified as callables in one of the modules of - `settings.PROTOTYPEFUNC_MODULES`, or specified on the command line. - - Args: - value (string): The value to test for a parseable protfunc. - available_functions (dict, optional): Mapping of name:protfunction to use for this parsing. - - Kwargs: - any (any): Passed on to the inlinefunc. - - Returns: - any (any): A structure to replace the string on the prototype level. If this is a - callable or a (callable, (args,)) structure, it will be executed as if one had supplied - it to the prototype directly. - - """ - if not isinstance(value, basestring): - return value - available_functions = _PROTOTYPEFUNCS if available_functions is None else available_functions - return inlinefuncs.parse_inlinefunc(value, _available_funcs=available_functions) - - -def _to_obj(value, force=True): - return dbid_to_obj(value, ObjectDB) - - -def _to_obj_or_any(value): - obj = dbid_to_obj(value, ObjectDB) - return obj if obj is not None else value - - -def validate_spawn_value(value, validator=None): - """ - Analyze the value and produce a value for use at the point of spawning. - - Args: - value (any): This can be: - callable - will be called as callable() - (callable, (args,)) - will be called as callable(*args) - other - will be assigned depending on the variable type - validator (callable, optional): If given, this will be called with the value to - check and guarantee the outcome is of a given type. - - Returns: - any (any): The (potentially pre-processed value to use for this prototype key) - - """ - value = protfunc_parser(value) - validator = validator if validator else lambda o: o - if callable(value): - return validator(value()) - elif value and is_iter(value) and callable(value[0]): - # a structure (callable, (args, )) - args = value[1:] - return validator(value[0](*make_iter(args))) - else: - return validator(value) - - -# Prototype storage mechanisms - - -class DbPrototype(DefaultScript): - """ - This stores a single prototype - """ - def at_script_creation(self): - self.key = "empty prototype" # prototype_key - self.desc = "A prototype" # prototype_desc - - - - - -def save_db_prototype(caller, prototype, key=None, desc=None, tags=None, locks="", delete=False): - """ - Store a prototype persistently. - - Args: - caller (Account or Object): Caller aiming to store prototype. At this point - the caller should have permission to 'add' new prototypes, but to edit - an existing prototype, the 'edit' lock must be passed on that prototype. - prototype (dict): Prototype dict. - key (str): Name of prototype to store. Will be inserted as `prototype_key` in the prototype. - desc (str, optional): Description of prototype, to use in listing. Will be inserted - as `prototype_desc` in the prototype. - tags (list, optional): Tag-strings to apply to prototype. These are always - applied with the 'db_prototype' category. Will be inserted as `prototype_tags`. - locks (str, optional): Locks to apply to this prototype. Used locks - are 'use' and 'edit'. Will be inserted as `prototype_locks` in the prototype. - delete (bool, optional): Delete an existing prototype identified by 'key'. - This requires `caller` to pass the 'edit' lock of the prototype. - Returns: - stored (StoredPrototype or None): The resulting prototype (new or edited), - or None if deleting. - Raises: - PermissionError: If edit lock was not passed by caller. - - - """ - key_orig = key or prototype.get('prototype_key', None) - if not key_orig: - caller.msg("This prototype requires a prototype_key.") - return False - key = str(key).lower() - - # we can't edit a prototype defined in a module - if key in _MODULE_PROTOTYPES: - mod = _MODULE_PROTOTYPE_MODULES.get(key, "N/A") - raise PermissionError("{} is a read-only prototype " - "(defined as code in {}).".format(key_orig, mod)) - - prototype['prototype_key'] = key - - if desc: - desc = prototype['prototype_desc'] = desc - else: - desc = prototype.get('prototype_desc', '') - - # set up locks and check they are on a valid form - locks = locks or prototype.get( - "prototype_locks", "use:all();edit:id({}) or perm(Admin)".format(caller.id)) - prototype['prototype_locks'] = locks - - is_valid, err = caller.locks.validate(locks) - if not is_valid: - caller.msg("Lock error: {}".format(err)) - return False - - if tags: - tags = [(tag, "db_prototype") for tag in make_iter(tags)] - else: - tags = prototype.get('prototype_tags', []) - prototype['prototype_tags'] = tags - - stored_prototype = DbPrototype.objects.filter(db_key=key) - - if stored_prototype: - # edit existing prototype - stored_prototype = stored_prototype[0] - if not stored_prototype.access(caller, 'edit'): - raise PermissionError("{} does not have permission to " - "edit prototype {}.".format(caller, key)) - - if delete: - # delete prototype - stored_prototype.delete() - return True - - if desc: - stored_prototype.desc = desc - if tags: - stored_prototype.tags.batch_add(*tags) - if locks: - stored_prototype.locks.add(locks) - if prototype: - stored_prototype.attributes.add("prototype", prototype) - elif delete: - # didn't find what to delete - return False - else: - # create a new prototype - stored_prototype = create_script( - DbPrototype, key=key, desc=desc, persistent=True, - locks=locks, tags=tags, attributes=[("prototype", prototype)]) - return stored_prototype - - -def delete_db_prototype(caller, key): - """ - Delete a stored prototype - - Args: - caller (Account or Object): Caller aiming to delete a prototype. - key (str): The persistent prototype to delete. - Returns: - success (bool): If deletion worked or not. - Raises: - PermissionError: If 'edit' lock was not passed. - - """ - return save_db_prototype(caller, key, None, delete=True) - - -def search_db_prototype(key=None, tags=None, return_queryset=False): - """ - Find persistent (database-stored) prototypes based on key and/or tags. - - Kwargs: - key (str): An exact or partial key to query for. - tags (str or list): Tag key or keys to query for. These - will always be applied with the 'db_protototype' - tag category. - return_queryset (bool, optional): Return the database queryset. - Return: - matches (queryset or list): All found DbPrototypes. If `return_queryset` - is not set, this is a list of prototype dicts. - - Note: - This does not include read-only prototypes defined in modules; use - `search_module_prototype` for those. - - """ - if tags: - # exact match on tag(s) - tags = make_iter(tags) - tag_categories = ["db_prototype" for _ in tags] - matches = DbPrototype.objects.get_by_tag(tags, tag_categories) - else: - matches = DbPrototype.objects.all() - if key: - # exact or partial match on key - matches = matches.filter(db_key=key) or matches.filter(db_key__icontains=key) - if not return_queryset: - # return prototype - matches = [dict(dbprot.attributes.get("prototype", {})) for dbprot in matches] - return matches - - -def search_module_prototype(key=None, tags=None): - """ - Find read-only prototypes, defined in modules. - - Kwargs: - key (str): An exact or partial key to query for. - tags (str or list): Tag key to query for. - - Return: - matches (list): List of prototypes matching the search criterion. - - """ - matches = {} - if tags: - # use tags to limit selection - tagset = set(tags) - matches = {prototype_key: prototype - for prototype_key, prototype in _MODULE_PROTOTYPES.items() - if tagset.intersection(prototype.get("prototype_tags", []))} - else: - matches = _MODULE_PROTOTYPES - - if key: - if key in matches: - # exact match - return [matches[key]] - else: - # fuzzy matching - return [prototype for prototype_key, prototype in matches.items() - if key in prototype_key] - else: - return [match for match in matches.values()] - - -def search_prototype(key=None, tags=None): - """ - Find prototypes based on key and/or tags, or all prototypes. - - Kwargs: - key (str): An exact or partial key to query for. - tags (str or list): Tag key or keys to query for. These - will always be applied with the 'db_protototype' - tag category. - - Return: - matches (list): All found prototype dicts. If no keys - or tags are given, all available prototypes will be returned. - - Note: - The available prototypes is a combination of those supplied in - PROTOTYPE_MODULES and those stored from in-game. For the latter, - this will use the tags to make a subselection before attempting - to match on the key. So if key/tags don't match up nothing will - be found. - - """ - module_prototypes = search_module_prototype(key, tags) - db_prototypes = search_db_prototype(key, tags) - - matches = db_prototypes + module_prototypes - if len(matches) > 1 and key: - key = key.lower() - # avoid duplicates if an exact match exist between the two types - filter_matches = [mta for mta in matches - if mta.get('prototype_key') and mta['prototype_key'] == key] - if filter_matches and len(filter_matches) < len(matches): - matches = filter_matches - - return matches - - -def search_objects_with_prototype(prototype_key): - """ - Retrieve all object instances created by a given prototype. - - Args: - prototype_key (str): The exact (and unique) prototype identifier to query for. - - Returns: - matches (Queryset): All matching objects spawned from this prototype. - - """ - return ObjectDB.objects.get_by_tag(key=prototype_key, category=_PROTOTYPE_TAG_CATEGORY) - - -def get_protparent_dict(): - """ - Get prototype parents. - - Returns: - parent_dict (dict): A mapping {prototype_key: prototype} for all available prototypes. - - """ - return {prototype['prototype_key']: prototype for prototype in search_prototype()} - - -def list_prototypes(caller, key=None, tags=None, show_non_use=False, show_non_edit=True): - """ - Collate a list of found prototypes based on search criteria and access. - - Args: - caller (Account or Object): The object requesting the list. - key (str, optional): Exact or partial prototype key to query for. - tags (str or list, optional): Tag key or keys to query for. - show_non_use (bool, optional): Show also prototypes the caller may not use. - show_non_edit (bool, optional): Show also prototypes the caller may not edit. - Returns: - table (EvTable or None): An EvTable representation of the prototypes. None - if no prototypes were found. - - """ - # this allows us to pass lists of empty strings - tags = [tag for tag in make_iter(tags) if tag] - - # get prototypes for readonly and db-based prototypes - prototypes = search_prototype(key, tags) - - # get use-permissions of readonly attributes (edit is always False) - display_tuples = [] - for prototype in sorted(prototypes, key=lambda d: d.get('prototype_key', '')): - lock_use = caller.locks.check_lockstring( - caller, prototype.get('prototype_locks', ''), access_type='use') - if not show_non_use and not lock_use: - continue - if prototype.get('prototype_key', '') in _MODULE_PROTOTYPES: - lock_edit = False - else: - lock_edit = caller.locks.check_lockstring( - caller, prototype.get('prototype_locks', ''), access_type='edit') - if not show_non_edit and not lock_edit: - continue - ptags = [] - for ptag in prototype.get('prototype_tags', []): - if is_iter(ptag): - if len(ptag) > 1: - ptags.append("{} (category: {}".format(ptag[0], ptag[1])) - else: - ptags.append(ptag[0]) - else: - ptags.append(str(ptag)) - - display_tuples.append( - (prototype.get('prototype_key', ''), - prototype.get('prototype_desc', ''), - "{}/{}".format('Y' if lock_use else 'N', 'Y' if lock_edit else 'N'), - ",".join(ptags))) - - if not display_tuples: - return None - - table = [] - width = 78 - for i in range(len(display_tuples[0])): - table.append([str(display_tuple[i]) for display_tuple in display_tuples]) - table = EvTable("Key", "Desc", "Use/Edit", "Tags", table=table, crop=True, width=width) - table.reformat_column(0, width=22) - table.reformat_column(1, width=29) - table.reformat_column(2, width=11, align='c') - table.reformat_column(3, width=16) - return table - - -def prototype_to_str(prototype): - """ - Format a prototype to a nice string representation. - - Args: - prototype (dict): The prototype. - """ - - header = ( - "|cprototype key:|n {}, |ctags:|n {}, |clocks:|n {} \n" - "|cdesc:|n {} \n|cprototype:|n ".format( - prototype['prototype_key'], - ", ".join(prototype['prototype_tags']), - prototype['prototype_locks'], - prototype['prototype_desc'])) - proto = ("{{\n {} \n}}".format( - "\n ".join( - "{!r}: {!r},".format(key, value) for key, value in - sorted(prototype.items()) if key not in _PROTOTYPE_META_NAMES)).rstrip(",")) - return header + proto - - -def prototype_from_object(obj): - """ - Guess a minimal prototype from an existing object. - - Args: - obj (Object): An object to analyze. - - Returns: - prototype (dict): A prototype estimating the current state of the object. - - """ - # first, check if this object already has a prototype - - prot = obj.tags.get(category=_PROTOTYPE_TAG_CATEGORY, return_list=True) - prot = search_prototype(prot) - if not prot or len(prot) > 1: - # no unambiguous prototype found - build new prototype - prot = {} - prot['prototype_key'] = "From-Object-{}-{}".format( - obj.key, hashlib.md5(str(time.time())).hexdigest()[:6]) - prot['prototype_desc'] = "Built from {}".format(str(obj)) - prot['prototype_locks'] = "use:all();edit:all()" - - prot['key'] = obj.db_key or hashlib.md5(str(time.time())).hexdigest()[:6] - prot['location'] = obj.db_location - prot['home'] = obj.db_home - prot['destination'] = obj.db_destination - prot['typeclass'] = obj.db_typeclass_path - prot['locks'] = obj.locks.all() - prot['permissions'] = obj.permissions.get() - prot['aliases'] = obj.aliases.get() - prot['tags'] = [(tag.key, tag.category, tag.data) - for tag in obj.tags.get(return_tagobj=True, return_list=True)] - prot['attrs'] = [(attr.key, attr.value, attr.category, attr.locks) - for attr in obj.attributes.get(return_obj=True, return_list=True)] - - return prot - -# Spawner mechanism - - -def validate_prototype(prototype, protkey=None, protparents=None, _visited=None): - """ - Run validation on a prototype, checking for inifinite regress. - - Args: - prototype (dict): Prototype to validate. - protkey (str, optional): The name of the prototype definition. If not given, the prototype - dict needs to have the `prototype_key` field set. - protpartents (dict, optional): The available prototype parent library. If - note given this will be determined from settings/database. - _visited (list, optional): This is an internal work array and should not be set manually. - Raises: - RuntimeError: If prototype has invalid structure. - - """ - if not protparents: - protparents = get_protparent_dict() - if _visited is None: - _visited = [] - - protkey = protkey and protkey.lower() or prototype.get('prototype_key', None) - - assert isinstance(prototype, dict) - - if id(prototype) in _visited: - raise RuntimeError("%s has infinite nesting of prototypes." % protkey or prototype) - - _visited.append(id(prototype)) - protstrings = prototype.get("prototype") - - if protstrings: - for protstring in make_iter(protstrings): - protstring = protstring.lower() - if protkey is not None and protstring == protkey: - raise RuntimeError("%s tries to prototype itself." % protkey or prototype) - protparent = protparents.get(protstring) - if not protparent: - raise RuntimeError( - "%s's prototype '%s' was not found." % (protkey or prototype, protstring)) - validate_prototype(protparent, protstring, protparents, _visited) - - -def _get_prototype(dic, prot, protparents): - """ - Recursively traverse a prototype dictionary, including multiple - inheritance. Use validate_prototype before this, we don't check - for infinite recursion here. - - """ - if "prototype" in dic: - # move backwards through the inheritance - for prototype in make_iter(dic["prototype"]): - # Build the prot dictionary in reverse order, overloading - new_prot = _get_prototype(protparents.get(prototype.lower(), {}), prot, protparents) - prot.update(new_prot) - prot.update(dic) - prot.pop("prototype", None) # we don't need this anymore - return prot - - -def prototype_diff_from_object(prototype, obj): - """ - Get a simple diff for a prototype compared to an object which may or may not already have a - prototype (or has one but changed locally). For more complex migratations a manual diff may be - needed. - - Args: - prototype (dict): Prototype. - obj (Object): Object to - - Returns: - diff (dict): Mapping for every prototype key: {"keyname": "REMOVE|UPDATE|KEEP", ...} - - """ - prot1 = prototype - prot2 = prototype_from_object(obj) - - diff = {} - for key, value in prot1.items(): - diff[key] = "KEEP" - if key in prot2: - if callable(prot2[key]) or value != prot2[key]: - diff[key] = "UPDATE" - elif key not in prot2: - diff[key] = "REMOVE" - - return diff - - -def batch_update_objects_with_prototype(prototype, diff=None, objects=None): - """ - Update existing objects with the latest version of the prototype. - - Args: - prototype (str or dict): Either the `prototype_key` to use or the - prototype dict itself. - diff (dict, optional): This a diff structure that describes how to update the protototype. If - not given this will be constructed from the first object found. - objects (list, optional): List of objects to update. If not given, query for these - objects using the prototype's `prototype_key`. - Returns: - changed (int): The number of objects that had changes applied to them. - - """ - prototype_key = prototype if isinstance(prototype, basestring) else prototype['prototype_key'] - prototype_obj = search_db_prototype(prototype_key, return_queryset=True) - prototype_obj = prototype_obj[0] if prototype_obj else None - new_prototype = prototype_obj.db.prototype - objs = ObjectDB.objects.get_by_tag(prototype_key, category=_PROTOTYPE_TAG_CATEGORY) - - if not objs: - return 0 - - if not diff: - diff = prototype_diff_from_object(new_prototype, objs[0]) - - changed = 0 - for obj in objs: - do_save = False - for key, directive in diff.items(): - val = new_prototype[key] - if directive in ('UPDATE', 'REPLACE'): - do_save = True - if key == 'key': - obj.db_key = validate_spawn_value(val, str) - elif key == 'typeclass': - obj.db_typeclass_path = validate_spawn_value(val, str) - elif key == 'location': - obj.db_location = validate_spawn_value(val, _to_obj) - elif key == 'home': - obj.db_home = validate_spawn_value(val, _to_obj) - elif key == 'destination': - obj.db_destination = validate_spawn_value(val, _to_obj) - elif key == 'locks': - if directive == 'REPLACE': - obj.locks.clear() - obj.locks.add(validate_spawn_value(val, str)) - elif key == 'permissions': - if directive == 'REPLACE': - obj.permissions.clear() - obj.permissions.batch_add(validate_spawn_value(val, make_iter)) - elif key == 'aliases': - if directive == 'REPLACE': - obj.aliases.clear() - obj.aliases.batch_add(validate_spawn_value(val, make_iter)) - elif key == 'tags': - if directive == 'REPLACE': - obj.tags.clear() - obj.tags.batch_add(validate_spawn_value(val, make_iter)) - elif key == 'attrs': - if directive == 'REPLACE': - obj.attributes.clear() - obj.attributes.batch_add(validate_spawn_value(val, make_iter)) - elif key == 'exec': - # we don't auto-rerun exec statements, it would be huge security risk! - pass - else: - obj.attributes.add(key, validate_spawn_value(val, _to_obj)) - elif directive == 'REMOVE': - do_save = True - if key == 'key': - obj.db_key = '' - elif key == 'typeclass': - # fall back to default - obj.db_typeclass_path = settings.BASE_OBJECT_TYPECLASS - elif key == 'location': - obj.db_location = None - elif key == 'home': - obj.db_home = None - elif key == 'destination': - obj.db_destination = None - elif key == 'locks': - obj.locks.clear() - elif key == 'permissions': - obj.permissions.clear() - elif key == 'aliases': - obj.aliases.clear() - elif key == 'tags': - obj.tags.clear() - elif key == 'attrs': - obj.attributes.clear() - elif key == 'exec': - # we don't auto-rerun exec statements, it would be huge security risk! - pass - else: - obj.attributes.remove(key) - if do_save: - changed += 1 - obj.save() - - return changed - - -def _batch_create_object(*objparams): - """ - This is a cut-down version of the create_object() function, - optimized for speed. It does NOT check and convert various input - so make sure the spawned Typeclass works before using this! - - Args: - objsparams (tuple): Each paremter tuple will create one object instance using the parameters within. - The parameters should be given in the following order: - - `create_kwargs` (dict): For use as new_obj = `ObjectDB(**create_kwargs)`. - - `permissions` (str): Permission string used with `new_obj.batch_add(permission)`. - - `lockstring` (str): Lockstring used with `new_obj.locks.add(lockstring)`. - - `aliases` (list): A list of alias strings for - adding with `new_object.aliases.batch_add(*aliases)`. - - `nattributes` (list): list of tuples `(key, value)` to be loop-added to - add with `new_obj.nattributes.add(*tuple)`. - - `attributes` (list): list of tuples `(key, value[,category[,lockstring]])` for - adding with `new_obj.attributes.batch_add(*attributes)`. - - `tags` (list): list of tuples `(key, category)` for adding - with `new_obj.tags.batch_add(*tags)`. - - `execs` (list): Code strings to execute together with the creation - of each object. They will be executed with `evennia` and `obj` - (the newly created object) available in the namespace. Execution - will happend after all other properties have been assigned and - is intended for calling custom handlers etc. - - Returns: - objects (list): A list of created objects - - Notes: - The `exec` list will execute arbitrary python code so don't allow this to be available to - unprivileged users! - - """ - - # bulk create all objects in one go - - # unfortunately this doesn't work since bulk_create doesn't creates pks; - # the result would be duplicate objects at the next stage, so we comment - # it out for now: - # dbobjs = _ObjectDB.objects.bulk_create(dbobjs) - - dbobjs = [ObjectDB(**objparam[0]) for objparam in objparams] - objs = [] - for iobj, obj in enumerate(dbobjs): - # call all setup hooks on each object - objparam = objparams[iobj] - # setup - obj._createdict = {"permissions": make_iter(objparam[1]), - "locks": objparam[2], - "aliases": make_iter(objparam[3]), - "nattributes": objparam[4], - "attributes": objparam[5], - "tags": make_iter(objparam[6])} - # this triggers all hooks - obj.save() - # run eventual extra code - for code in objparam[7]: - if code: - exec(code, {}, {"evennia": evennia, "obj": obj}) - objs.append(obj) - return objs - - -def spawn(*prototypes, **kwargs): - """ - Spawn a number of prototyped objects. - - Args: - prototypes (dict): Each argument should be a prototype - dictionary. - Kwargs: - prototype_modules (str or list): A python-path to a prototype - module, or a list of such paths. These will be used to build - the global protparents dictionary accessible by the input - prototypes. If not given, it will instead look for modules - defined by settings.PROTOTYPE_MODULES. - prototype_parents (dict): A dictionary holding a custom - prototype-parent dictionary. Will overload same-named - prototypes from prototype_modules. - return_prototypes (bool): Only return a list of the - prototype-parents (no object creation happens) - - Returns: - object (Object): Spawned object. - - """ - # get available protparents - protparents = get_protparent_dict() - - # overload module's protparents with specifically given protparents - protparents.update(kwargs.get("prototype_parents", {})) - for key, prototype in protparents.items(): - validate_prototype(prototype, key.lower(), protparents) - - if "return_prototypes" in kwargs: - # only return the parents - return copy.deepcopy(protparents) - - objsparams = [] - for prototype in prototypes: - - validate_prototype(prototype, None, protparents) - prot = _get_prototype(prototype, {}, protparents) - if not prot: - continue - - # extract the keyword args we need to create the object itself. If we get a callable, - # call that to get the value (don't catch errors) - create_kwargs = {} - # we must always add a key, so if not given we use a shortened md5 hash. There is a (small) - # chance this is not unique but it should usually not be a problem. - val = prot.pop("key", "Spawned-{}".format( - hashlib.md5(str(time.time())).hexdigest()[:6])) - create_kwargs["db_key"] = validate_spawn_value(val, str) - - val = prot.pop("location", None) - create_kwargs["db_location"] = validate_spawn_value(val, _to_obj) - - val = prot.pop("home", settings.DEFAULT_HOME) - create_kwargs["db_home"] = validate_spawn_value(val, _to_obj) - - val = prot.pop("destination", None) - create_kwargs["db_destination"] = validate_spawn_value(val, _to_obj) - - val = prot.pop("typeclass", settings.BASE_OBJECT_TYPECLASS) - create_kwargs["db_typeclass_path"] = validate_spawn_value(val, str) - - # extract calls to handlers - val = prot.pop("permissions", []) - permission_string = validate_spawn_value(val, make_iter) - val = prot.pop("locks", "") - lock_string = validate_spawn_value(val, str) - val = prot.pop("aliases", []) - alias_string = validate_spawn_value(val, make_iter) - - val = prot.pop("tags", []) - tags = validate_spawn_value(val, make_iter) - - prototype_key = prototype.get('prototype_key', None) - if prototype_key: - # we make sure to add a tag identifying which prototype created this object - tags.append((prototype_key, _PROTOTYPE_TAG_CATEGORY)) - - val = prot.pop("exec", "") - execs = validate_spawn_value(val, make_iter) - - # extract ndb assignments - nattribute = dict((key.split("_", 1)[1], validate_spawn_value(val, _to_obj)) - for key, val in prot.items() if key.startswith("ndb_")) - - # the rest are attributes - val = prot.pop("attrs", []) - attributes = validate_spawn_value(val, list) - - simple_attributes = [] - for key, value in ((key, value) for key, value in prot.items() - if not (key.startswith("ndb_"))): - if is_iter(value) and len(value) > 1: - # (value, category) - simple_attributes.append((key, - validate_spawn_value(value[0], _to_obj_or_any), - validate_spawn_value(value[1], str))) - else: - simple_attributes.append((key, - validate_spawn_value(value, _to_obj_or_any))) - - attributes = attributes + simple_attributes - attributes = [tup for tup in attributes if not tup[0] in _NON_CREATE_KWARGS] - - # pack for call into _batch_create_object - objsparams.append((create_kwargs, permission_string, lock_string, - alias_string, nattributes, attributes, tags, execs)) - - return _batch_create_object(*objsparams) - - -# ------------------------------------------------------------ -# -# OLC Prototype design menu -# -# ------------------------------------------------------------ - -# Helper functions - -def _get_menu_prototype(caller): - - prototype = None - if hasattr(caller.ndb._menutree, "olc_prototype"): - prototype = caller.ndb._menutree.olc_prototype - if not prototype: - caller.ndb._menutree.olc_prototype = prototype = {} - caller.ndb._menutree.olc_new = True - return prototype - - -def _is_new_prototype(caller): - return hasattr(caller.ndb._menutree, "olc_new") - - -def _set_menu_prototype(caller, field, value): - prototype = _get_menu_prototype(caller) - prototype[field] = value - caller.ndb._menutree.olc_prototype = prototype - - -def _format_property(prop, required=False, prototype=None, cropper=None): - - if prototype is not None: - prop = prototype.get(prop, '') - - out = prop - if callable(prop): - if hasattr(prop, '__name__'): - out = "<{}>".format(prop.__name__) - else: - out = repr(prop) - if is_iter(prop): - out = ", ".join(str(pr) for pr in prop) - if not out and required: - out = "|rrequired" - return " ({}|n)".format(cropper(out) if cropper else crop(out, _MENU_CROP_WIDTH)) - - -def _set_property(caller, raw_string, **kwargs): - """ - Update a property. To be called by the 'goto' option variable. - - Args: - caller (Object, Account): The user of the wizard. - raw_string (str): Input from user on given node - the new value to set. - Kwargs: - prop (str): Property name to edit with `raw_string`. - processor (callable): Converts `raw_string` to a form suitable for saving. - next_node (str): Where to redirect to after this has run. - Returns: - next_node (str): Next node to go to. - - """ - prop = kwargs.get("prop", "prototype_key") - processor = kwargs.get("processor", None) - next_node = kwargs.get("next_node", "node_index") - - propname_low = prop.strip().lower() - - if callable(processor): - try: - value = processor(raw_string) - except Exception as err: - caller.msg("Could not set {prop} to {value} ({err})".format( - prop=prop.replace("_", "-").capitalize(), value=raw_string, err=str(err))) - # this means we'll re-run the current node. - return None - else: - value = raw_string - - if not value: - return next_node - - prototype = _get_menu_prototype(caller) - - # typeclass and prototype can't co-exist - if propname_low == "typeclass": - prototype.pop("prototype", None) - if propname_low == "prototype": - prototype.pop("typeclass", None) - - caller.ndb._menutree.olc_prototype = prototype - - caller.msg("Set {prop} to '{value}'.".format(prop, value=str(value))) - - return next_node - - -def _wizard_options(curr_node, prev_node, next_node, color="|W"): - options = [] - if prev_node: - options.append({"key": ("|wb|Wack", "b"), - "desc": "{color}({node})|n".format( - color=color, node=prev_node.replace("_", "-")), - "goto": "node_{}".format(prev_node)}) - if next_node: - options.append({"key": ("|wf|Worward", "f"), - "desc": "{color}({node})|n".format( - color=color, node=next_node.replace("_", "-")), - "goto": "node_{}".format(next_node)}) - - if "index" not in (prev_node, next_node): - options.append({"key": ("|wi|Wndex", "i"), - "goto": "node_index"}) - - if curr_node: - options.append({"key": ("|wv|Walidate prototype", "v"), - "goto": ("node_validate_prototype", {"back": curr_node})}) - - return options - - -def _path_cropper(pythonpath): - "Crop path to only the last component" - return pythonpath.split('.')[-1] - - -# Menu nodes - -def node_index(caller): - prototype = _get_menu_prototype(caller) - - text = ("|c --- Prototype wizard --- |n\n\n" - "Define the |yproperties|n of the prototype. All prototype values can be " - "over-ridden at the time of spawning an instance of the prototype, but some are " - "required.\n\n'|wMeta'-properties|n are not used in the prototype itself but are used " - "to organize and list prototypes. The 'Meta-Key' uniquely identifies the prototype " - "and allows you to edit an existing prototype or save a new one for use by you or " - "others later.\n\n(make choice; q to abort. If unsure, start from 1.)") - - options = [] - options.append( - {"desc": "|WPrototype-Key|n|n{}".format(_format_property("Key", True, prototype, None)), - "goto": "node_prototype_key"}) - for key in ('Prototype', 'Typeclass', 'Key', 'Aliases', 'Attrs', 'Tags', 'Locks', - 'Permissions', 'Location', 'Home', 'Destination'): - required = False - cropper = None - if key in ("Prototype", "Typeclass"): - required = "prototype" not in prototype and "typeclass" not in prototype - if key == 'Typeclass': - cropper = _path_cropper - options.append( - {"desc": "|w{}|n{}".format( - key, _format_property(key, required, prototype, cropper=cropper)), - "goto": "node_{}".format(key.lower())}) - required = False - for key in ('Desc', 'Tags', 'Locks'): - options.append( - {"desc": "|WPrototype-{}|n|n{}".format(key, _format_property(key, required, prototype, None)), - "goto": "node_prototype_{}".format(key.lower())}) - - return text, options - - -def node_validate_prototype(caller, raw_string, **kwargs): - prototype = _get_menu_prototype(caller) - - txt = prototype_to_str(prototype) - errors = "\n\n|g No validation errors found.|n (but errors could still happen at spawn-time)" - try: - # validate, don't spawn - spawn(prototype, return_prototypes=True) - except RuntimeError as err: - errors = "\n\n|rError: {}|n".format(err) - text = (txt + errors) - - options = _wizard_options(None, kwargs.get("back"), None) - - return text, options - - -def _check_prototype_key(caller, key): - old_prototype = search_prototype(key) - olc_new = _is_new_prototype(caller) - key = key.strip().lower() - if old_prototype: - # we are starting a new prototype that matches an existing - if not caller.locks.check_lockstring( - caller, old_prototype['prototype_locks'], access_type='edit'): - # return to the node_prototype_key to try another key - caller.msg("Prototype '{key}' already exists and you don't " - "have permission to edit it.".format(key=key)) - return "node_prototype_key" - elif olc_new: - # we are selecting an existing prototype to edit. Reset to index. - del caller.ndb._menutree.olc_new - caller.ndb._menutree.olc_prototype = old_prototype - caller.msg("Prototype already exists. Reloading.") - return "node_index" - - return _set_property(caller, key, prop='prototype_key', next_node="node_prototype") - - -def node_prototype_key(caller): - prototype = _get_menu_prototype(caller) - text = ["The prototype name, or |wMeta-Key|n, uniquely identifies the prototype. " - "It is used to find and use the prototype to spawn new entities. " - "It is not case sensitive."] - old_key = prototype.get('prototype_key', None) - if old_key: - text.append("Current key is '|w{key}|n'".format(key=old_key)) - else: - text.append("The key is currently unset.") - text.append("Enter text or make a choice (q for quit)") - text = "\n\n".join(text) - options = _wizard_options("prototype_key", "index", "prototype") - options.append({"key": "_default", - "goto": _check_prototype_key}) - return text, options - - -def _all_prototypes(caller): - return [prototype["prototype_key"] - for prototype in search_prototype() if "prototype_key" in prototype] - - -def _prototype_examine(caller, prototype_name): - prototypes = search_prototype(key=prototype_name) - if prototypes: - caller.msg(prototype_to_str(prototypes[0])) - caller.msg("Prototype not registered.") - return None - - -def _prototype_select(caller, prototype): - ret = _set_property(caller, prototype, prop="prototype", processor=str, next_node="node_key") - caller.msg("Selected prototype |y{}|n. Removed any set typeclass parent.".format(prototype)) - return ret - - -@list_node(_all_prototypes, _prototype_select) -def node_prototype(caller): - prototype = _get_menu_prototype(caller) - - prot_parent_key = prototype.get('prototype') - - text = ["Set the prototype's |yParent Prototype|n. If this is unset, Typeclass will be used."] - if prot_parent_key: - prot_parent = search_prototype(prot_parent_key) - if prot_parent: - text.append("Current parent prototype is {}:\n{}".format(prototype_to_str(prot_parent))) - else: - text.append("Current parent prototype |r{prototype}|n " - "does not appear to exist.".format(prot_parent_key)) - else: - text.append("Parent prototype is not set") - text = "\n\n".join(text) - options = _wizard_options("prototype", "prototype_key", "typeclass", color="|W") - options.append({"key": "_default", - "goto": _prototype_examine}) - - return text, options - - -def _all_typeclasses(caller): - return list(sorted(get_all_typeclasses().keys())) - - -def _typeclass_examine(caller, typeclass_path): - if typeclass_path is None: - # this means we are exiting the listing - return "node_key" - - typeclass = get_all_typeclasses().get(typeclass_path) - if typeclass: - docstr = [] - for line in typeclass.__doc__.split("\n"): - if line.strip(): - docstr.append(line) - elif docstr: - break - docstr = '\n'.join(docstr) if docstr else "" - txt = "Typeclass |y{typeclass_path}|n; First paragraph of docstring:\n\n{docstring}".format( - typeclass_path=typeclass_path, docstring=docstr) - else: - txt = "This is typeclass |y{}|n.".format(typeclass) - caller.msg(txt) - return None - - -def _typeclass_select(caller, typeclass): - ret = _set_property(caller, typeclass, prop='typeclass', processor=str, next_node="node_key") - caller.msg("Selected typeclass |y{}|n. Removed any set prototype parent.".format(typeclass)) - return ret - - -@list_node(_all_typeclasses, _typeclass_select) -def node_typeclass(caller): - prototype = _get_menu_prototype(caller) - typeclass = prototype.get("typeclass") - - text = ["Set the typeclass's parent |yTypeclass|n."] - if typeclass: - text.append("Current typeclass is |y{typeclass}|n.".format(typeclass=typeclass)) - else: - text.append("Using default typeclass {typeclass}.".format( - typeclass=settings.BASE_OBJECT_TYPECLASS)) - text = "\n\n".join(text) - options = _wizard_options("typeclass", "prototype", "key", color="|W") - options.append({"key": "_default", - "goto": _typeclass_examine}) - return text, options - - -def node_key(caller): - prototype = _get_menu_prototype(caller) - key = prototype.get("key") - - text = ["Set the prototype's |yKey|n. This will retain case sensitivity."] - if key: - text.append("Current key value is '|y{key}|n'.".format(key=key)) - else: - text.append("Key is currently unset.") - text = "\n\n".join(text) - options = _wizard_options("key", "typeclass", "aliases") - options.append({"key": "_default", - "goto": (_set_property, - dict(prop="key", - processor=lambda s: s.strip(), - next_node="node_aliases"))}) - return text, options - - -def node_aliases(caller): - prototype = _get_menu_prototype(caller) - aliases = prototype.get("aliases") - - text = ["Set the prototype's |yAliases|n. Separate multiple aliases with commas. " - "ill retain case sensitivity."] - if aliases: - text.append("Current aliases are '|y{aliases}|n'.".format(aliases=aliases)) - else: - text.append("No aliases are set.") - text = "\n\n".join(text) - options = _wizard_options("aliases", "key", "attrs") - options.append({"key": "_default", - "goto": (_set_property, - dict(prop="aliases", - processor=lambda s: [part.strip() for part in s.split(",")], - next_node="node_attrs"))}) - return text, options - - -def _caller_attrs(caller): - prototype = _get_menu_prototype(caller) - attrs = prototype.get("attrs", []) - return attrs - - -def _attrparse(caller, attr_string): - "attr is entering on the form 'attr = value'" - - if '=' in attr_string: - attrname, value = (part.strip() for part in attr_string.split('=', 1)) - attrname = attrname.lower() - if attrname: - try: - value = literal_eval(value) - except SyntaxError: - caller.msg(_MENU_ATTR_LITERAL_EVAL_ERROR) - else: - return attrname, value - else: - return None, None - - -def _add_attr(caller, attr_string, **kwargs): - attrname, value = _attrparse(caller, attr_string) - if attrname: - prot = _get_menu_prototype(caller) - prot['attrs'][attrname] = value - _set_menu_prototype(caller, "prototype", prot) - text = "Added" - else: - text = "Attribute must be given as 'attrname = ' where uses valid Python." - options = {"key": "_default", - "goto": lambda caller: None} - return text, options - - -def _edit_attr(caller, attrname, new_value, **kwargs): - attrname, value = _attrparse("{}={}".format(caller, attrname, new_value)) - if attrname: - prot = _get_menu_prototype(caller) - prot['attrs'][attrname] = value - text = "Edited Attribute {} = {}".format(attrname, value) - else: - text = "Attribute value must be valid Python." - options = {"key": "_default", - "goto": lambda caller: None} - return text, options - - -def _examine_attr(caller, selection): - prot = _get_menu_prototype(caller) - value = prot['attrs'][selection] - return "Attribute {} = {}".format(selection, value) - - -@list_node(_caller_attrs) -def node_attrs(caller): - prot = _get_menu_prototype(caller) - attrs = prot.get("attrs") - - text = ["Set the prototype's |yAttributes|n. Separate multiple attrs with commas. " - "Will retain case sensitivity."] - if attrs: - text.append("Current attrs are '|y{attrs}|n'.".format(attrs=attrs)) - else: - text.append("No attrs are set.") - text = "\n\n".join(text) - options = _wizard_options("attrs", "aliases", "tags") - options.append({"key": "_default", - "goto": (_set_property, - dict(prop="attrs", - processor=lambda s: [part.strip() for part in s.split(",")], - next_node="node_tags"))}) - return text, options - - -def _caller_tags(caller): - prototype = _get_menu_prototype(caller) - tags = prototype.get("tags") - return tags - - -def _add_tag(caller, tag, **kwargs): - tag = tag.strip().lower() - prototype = _get_menu_prototype(caller) - tags = prototype.get('tags', []) - if tags: - if tag not in tags: - tags.append(tag) - else: - tags = [tag] - prot['tags'] = tags - _set_menu_prototype(caller, "prototype", prot) - text = kwargs.get("text") - if not text: - text = "Added tag {}. (return to continue)".format(tag) - options = {"key": "_default", - "goto": lambda caller: None} - return text, options - - -def _edit_tag(caller, old_tag, new_tag, **kwargs): - prototype = _get_menu_prototype(caller) - tags = prototype.get('tags', []) - - old_tag = old_tag.strip().lower() - new_tag = new_tag.strip().lower() - tags[tags.index(old_tag)] = new_tag - prototype['tags'] = tags - _set_menu_prototype(caller, 'prototype', prototype) - - text = kwargs.get('text') - if not text: - text = "Changed tag {} to {}.".format(old_tag, new_tag) - options = {"key": "_default", - "goto": lambda caller: None} - return text, options - - -@list_node(_caller_tags) -def node_tags(caller): - text = "Set the prototype's |yTags|n." - options = _wizard_options("tags", "attrs", "locks") - return text, options - - -def node_locks(caller): - prototype = _get_menu_prototype(caller) - locks = prototype.get("locks") - - text = ["Set the prototype's |yLock string|n. Separate multiple locks with semi-colons. " - "Will retain case sensitivity."] - if locks: - text.append("Current locks are '|y{locks}|n'.".format(locks=locks)) - else: - text.append("No locks are set.") - text = "\n\n".join(text) - options = _wizard_options("locks", "tags", "permissions") - options.append({"key": "_default", - "goto": (_set_property, - dict(prop="locks", - processor=lambda s: s.strip(), - next_node="node_permissions"))}) - return text, options - - -def node_permissions(caller): - prototype = _get_menu_prototype(caller) - permissions = prototype.get("permissions") - - text = ["Set the prototype's |yPermissions|n. Separate multiple permissions with commas. " - "Will retain case sensitivity."] - if permissions: - text.append("Current permissions are '|y{permissions}|n'.".format(permissions=permissions)) - else: - text.append("No permissions are set.") - text = "\n\n".join(text) - options = _wizard_options("permissions", "destination", "location") - options.append({"key": "_default", - "goto": (_set_property, - dict(prop="permissions", - processor=lambda s: [part.strip() for part in s.split(",")], - next_node="node_location"))}) - return text, options - - -def node_location(caller): - prototype = _get_menu_prototype(caller) - location = prototype.get("location") - - text = ["Set the prototype's |yLocation|n"] - if location: - text.append("Current location is |y{location}|n.".format(location=location)) - else: - text.append("Default location is {}'s inventory.".format(caller)) - text = "\n\n".join(text) - options = _wizard_options("location", "permissions", "home") - options.append({"key": "_default", - "goto": (_set_property, - dict(prop="location", - processor=lambda s: s.strip(), - next_node="node_home"))}) - return text, options - - -def node_home(caller): - prototype = _get_menu_prototype(caller) - home = prototype.get("home") - - text = ["Set the prototype's |yHome location|n"] - if home: - text.append("Current home location is |y{home}|n.".format(home=home)) - else: - text.append("Default home location (|y{home}|n) used.".format(home=settings.DEFAULT_HOME)) - text = "\n\n".join(text) - options = _wizard_options("home", "aliases", "destination") - options.append({"key": "_default", - "goto": (_set_property, - dict(prop="home", - processor=lambda s: s.strip(), - next_node="node_destination"))}) - return text, options - - -def node_destination(caller): - prototype = _get_menu_prototype(caller) - dest = prototype.get("dest") - - text = ["Set the prototype's |yDestination|n. This is usually only used for Exits."] - if dest: - text.append("Current destination is |y{dest}|n.".format(dest=dest)) - else: - text.append("No destination is set (default).") - text = "\n\n".join(text) - options = _wizard_options("destination", "home", "prototype_desc") - options.append({"key": "_default", - "goto": (_set_property, - dict(prop="dest", - processor=lambda s: s.strip(), - next_node="node_prototype_desc"))}) - return text, options - - -def node_prototype_desc(caller): - - prototype = _get_menu_prototype(caller) - text = ["The |wMeta-Description|n briefly describes the prototype for viewing in listings."] - desc = prototype.get("prototype_desc", None) - - if desc: - text.append("The current meta desc is:\n\"|w{desc}|n\"".format(desc=desc)) - else: - text.append("Description is currently unset.") - text = "\n\n".join(text) - options = _wizard_options("prototype_desc", "prototype_key", "prototype_tags") - options.append({"key": "_default", - "goto": (_set_property, - dict(prop='prototype_desc', - processor=lambda s: s.strip(), - next_node="node_prototype_tags"))}) - - return text, options - - -def node_prototype_tags(caller): - prototype = _get_menu_prototype(caller) - text = ["|wMeta-Tags|n can be used to classify and find prototypes. Tags are case-insensitive. " - "Separate multiple by tags by commas."] - tags = prototype.get('prototype_tags', []) - - if tags: - text.append("The current tags are:\n|w{tags}|n".format(tags=tags)) - else: - text.append("No tags are currently set.") - text = "\n\n".join(text) - options = _wizard_options("prototype_tags", "prototype_desc", "prototype_locks") - options.append({"key": "_default", - "goto": (_set_property, - dict(prop="prototype_tags", - processor=lambda s: [ - str(part.strip().lower()) for part in s.split(",")], - next_node="node_prototype_locks"))}) - return text, options - - -def node_prototype_locks(caller): - prototype = _get_menu_prototype(caller) - text = ["Set |wMeta-Locks|n on the prototype. There are two valid lock types: " - "'edit' (who can edit the prototype) and 'use' (who can apply the prototype)\n" - "(If you are unsure, leave as default.)"] - locks = prototype.get('prototype_locks', '') - if locks: - text.append("Current lock is |w'{lockstring}'|n".format(lockstring=locks)) - else: - text.append("Lock unset - if not changed the default lockstring will be set as\n" - " |w'use:all(); edit:id({dbref}) or perm(Admin)'|n".format(dbref=caller.id)) - text = "\n\n".join(text) - options = _wizard_options("prototype_locks", "prototype_tags", "index") - options.append({"key": "_default", - "goto": (_set_property, - dict(prop="prototype_locks", - processor=lambda s: s.strip().lower(), - next_node="node_index"))}) - return text, options - - -class OLCMenu(EvMenu): - """ - A custom EvMenu with a different formatting for the options. - - """ - def options_formatter(self, optionlist): - """ - Split the options into two blocks - olc options and normal options - - """ - olc_keys = ("index", "forward", "back", "previous", "next", "validate prototype") - olc_options = [] - other_options = [] - for key, desc in optionlist: - raw_key = strip_ansi(key) - if raw_key in olc_keys: - desc = " {}".format(desc) if desc else "" - olc_options.append("|lc{}|lt{}|le{}".format(raw_key, key, desc)) - else: - other_options.append((key, desc)) - - olc_options = " | ".join(olc_options) + " | " + "|wq|Wuit" if olc_options else "" - other_options = super(OLCMenu, self).options_formatter(other_options) - sep = "\n\n" if olc_options and other_options else "" - - return "{}{}{}".format(olc_options, sep, other_options) - - -def start_olc(caller, session=None, prototype=None): - """ - Start menu-driven olc system for prototypes. - - Args: - caller (Object or Account): The entity starting the menu. - session (Session, optional): The individual session to get data. - prototype (dict, optional): Given when editing an existing - prototype rather than creating a new one. - - """ - menudata = {"node_index": node_index, - "node_validate_prototype": node_validate_prototype, - "node_prototype_key": node_prototype_key, - "node_prototype": node_prototype, - "node_typeclass": node_typeclass, - "node_key": node_key, - "node_aliases": node_aliases, - "node_attrs": node_attrs, - "node_tags": node_tags, - "node_locks": node_locks, - "node_permissions": node_permissions, - "node_location": node_location, - "node_home": node_home, - "node_destination": node_destination, - "node_prototype_desc": node_prototype_desc, - "node_prototype_tags": node_prototype_tags, - "node_prototype_locks": node_prototype_locks, - } - OLCMenu(caller, menudata, startnode='node_index', session=session, olc_prototype=prototype) - - -# Testing - -if __name__ == "__main__": - protparents = { - "NOBODY": {}, - # "INFINITE" : { - # "prototype":"INFINITE" - # }, - "GOBLIN": { - "key": "goblin grunt", - "health": lambda: randint(20, 30), - "resists": ["cold", "poison"], - "attacks": ["fists"], - "weaknesses": ["fire", "light"] - }, - "GOBLIN_WIZARD": { - "prototype": "GOBLIN", - "key": "goblin wizard", - "spells": ["fire ball", "lighting bolt"] - }, - "GOBLIN_ARCHER": { - "prototype": "GOBLIN", - "key": "goblin archer", - "attacks": ["short bow"] - }, - "ARCHWIZARD": { - "attacks": ["archwizard staff"], - }, - "GOBLIN_ARCHWIZARD": { - "key": "goblin archwizard", - "prototype": ("GOBLIN_WIZARD", "ARCHWIZARD") - } - } - # test - print([o.key for o in spawn(protparents["GOBLIN"], - protparents["GOBLIN_ARCHWIZARD"], - prototype_parents=protparents)]) From fef8b3bf6a7c6626613c2500644470e64d6995ef Mon Sep 17 00:00:00 2001 From: Griatch Date: Wed, 6 Jun 2018 22:11:32 +0200 Subject: [PATCH 132/208] Continued refactoring --- evennia/prototypes/prototypes.py | 274 +++++++++++++++++++++++++++++++ evennia/prototypes/spawner.py | 240 +-------------------------- evennia/prototypes/utils.py | 128 +++------------ 3 files changed, 295 insertions(+), 347 deletions(-) diff --git a/evennia/prototypes/prototypes.py b/evennia/prototypes/prototypes.py index 60e194861b..e3d26fd87e 100644 --- a/evennia/prototypes/prototypes.py +++ b/evennia/prototypes/prototypes.py @@ -61,6 +61,7 @@ class DbPrototype(DefaultScript): # General prototype functions + def check_permission(prototype_key, action, default=True): """ Helper function to check access to actions on given prototype. @@ -278,3 +279,276 @@ def search_objects_with_prototype(prototype_key): """ return ObjectDB.objects.get_by_tag(key=prototype_key, category=_PROTOTYPE_TAG_CATEGORY) + + +def prototype_from_object(obj): + """ + Guess a minimal prototype from an existing object. + + Args: + obj (Object): An object to analyze. + + Returns: + prototype (dict): A prototype estimating the current state of the object. + + """ + # first, check if this object already has a prototype + + prot = obj.tags.get(category=_PROTOTYPE_TAG_CATEGORY, return_list=True) + prot = search_prototype(prot) + if not prot or len(prot) > 1: + # no unambiguous prototype found - build new prototype + prot = {} + prot['prototype_key'] = "From-Object-{}-{}".format( + obj.key, hashlib.md5(str(time.time())).hexdigest()[:6]) + prot['prototype_desc'] = "Built from {}".format(str(obj)) + prot['prototype_locks'] = "spawn:all();edit:all()" + + prot['key'] = obj.db_key or hashlib.md5(str(time.time())).hexdigest()[:6] + prot['location'] = obj.db_location + prot['home'] = obj.db_home + prot['destination'] = obj.db_destination + prot['typeclass'] = obj.db_typeclass_path + prot['locks'] = obj.locks.all() + prot['permissions'] = obj.permissions.get() + prot['aliases'] = obj.aliases.get() + prot['tags'] = [(tag.key, tag.category, tag.data) + for tag in obj.tags.get(return_tagobj=True, return_list=True)] + prot['attrs'] = [(attr.key, attr.value, attr.category, attr.locks) + for attr in obj.attributes.get(return_obj=True, return_list=True)] + + return prot + + +def list_prototypes(caller, key=None, tags=None, show_non_use=False, show_non_edit=True): + """ + Collate a list of found prototypes based on search criteria and access. + + Args: + caller (Account or Object): The object requesting the list. + key (str, optional): Exact or partial prototype key to query for. + tags (str or list, optional): Tag key or keys to query for. + show_non_use (bool, optional): Show also prototypes the caller may not use. + show_non_edit (bool, optional): Show also prototypes the caller may not edit. + Returns: + table (EvTable or None): An EvTable representation of the prototypes. None + if no prototypes were found. + + """ + # this allows us to pass lists of empty strings + tags = [tag for tag in make_iter(tags) if tag] + + # get prototypes for readonly and db-based prototypes + prototypes = search_prototype(key, tags) + + # get use-permissions of readonly attributes (edit is always False) + display_tuples = [] + for prototype in sorted(prototypes, key=lambda d: d.get('prototype_key', '')): + lock_use = caller.locks.check_lockstring( + caller, prototype.get('prototype_locks', ''), access_type='spawn') + if not show_non_use and not lock_use: + continue + if prototype.get('prototype_key', '') in _MODULE_PROTOTYPES: + lock_edit = False + else: + lock_edit = caller.locks.check_lockstring( + caller, prototype.get('prototype_locks', ''), access_type='edit') + if not show_non_edit and not lock_edit: + continue + ptags = [] + for ptag in prototype.get('prototype_tags', []): + if is_iter(ptag): + if len(ptag) > 1: + ptags.append("{} (category: {}".format(ptag[0], ptag[1])) + else: + ptags.append(ptag[0]) + else: + ptags.append(str(ptag)) + + display_tuples.append( + (prototype.get('prototype_key', ''), + prototype.get('prototype_desc', ''), + "{}/{}".format('Y' if lock_use else 'N', 'Y' if lock_edit else 'N'), + ",".join(ptags))) + + if not display_tuples: + return None + + table = [] + width = 78 + for i in range(len(display_tuples[0])): + table.append([str(display_tuple[i]) for display_tuple in display_tuples]) + table = EvTable("Key", "Desc", "Spawn/Edit", "Tags", table=table, crop=True, width=width) + table.reformat_column(0, width=22) + table.reformat_column(1, width=29) + table.reformat_column(2, width=11, align='c') + table.reformat_column(3, width=16) + return table + + + +def batch_update_objects_with_prototype(prototype, diff=None, objects=None): + """ + Update existing objects with the latest version of the prototype. + + Args: + prototype (str or dict): Either the `prototype_key` to use or the + prototype dict itself. + diff (dict, optional): This a diff structure that describes how to update the protototype. + If not given this will be constructed from the first object found. + objects (list, optional): List of objects to update. If not given, query for these + objects using the prototype's `prototype_key`. + Returns: + changed (int): The number of objects that had changes applied to them. + + """ + prototype_key = prototype if isinstance(prototype, basestring) else prototype['prototype_key'] + prototype_obj = search_db_prototype(prototype_key, return_queryset=True) + prototype_obj = prototype_obj[0] if prototype_obj else None + new_prototype = prototype_obj.db.prototype + objs = ObjectDB.objects.get_by_tag(prototype_key, category=_PROTOTYPE_TAG_CATEGORY) + + if not objs: + return 0 + + if not diff: + diff = prototype_diff_from_object(new_prototype, objs[0]) + + changed = 0 + for obj in objs: + do_save = False + for key, directive in diff.items(): + val = new_prototype[key] + if directive in ('UPDATE', 'REPLACE'): + do_save = True + if key == 'key': + obj.db_key = validate_spawn_value(val, str) + elif key == 'typeclass': + obj.db_typeclass_path = validate_spawn_value(val, str) + elif key == 'location': + obj.db_location = validate_spawn_value(val, _to_obj) + elif key == 'home': + obj.db_home = validate_spawn_value(val, _to_obj) + elif key == 'destination': + obj.db_destination = validate_spawn_value(val, _to_obj) + elif key == 'locks': + if directive == 'REPLACE': + obj.locks.clear() + obj.locks.add(validate_spawn_value(val, str)) + elif key == 'permissions': + if directive == 'REPLACE': + obj.permissions.clear() + obj.permissions.batch_add(validate_spawn_value(val, make_iter)) + elif key == 'aliases': + if directive == 'REPLACE': + obj.aliases.clear() + obj.aliases.batch_add(validate_spawn_value(val, make_iter)) + elif key == 'tags': + if directive == 'REPLACE': + obj.tags.clear() + obj.tags.batch_add(validate_spawn_value(val, make_iter)) + elif key == 'attrs': + if directive == 'REPLACE': + obj.attributes.clear() + obj.attributes.batch_add(validate_spawn_value(val, make_iter)) + elif key == 'exec': + # we don't auto-rerun exec statements, it would be huge security risk! + pass + else: + obj.attributes.add(key, validate_spawn_value(val, _to_obj)) + elif directive == 'REMOVE': + do_save = True + if key == 'key': + obj.db_key = '' + elif key == 'typeclass': + # fall back to default + obj.db_typeclass_path = settings.BASE_OBJECT_TYPECLASS + elif key == 'location': + obj.db_location = None + elif key == 'home': + obj.db_home = None + elif key == 'destination': + obj.db_destination = None + elif key == 'locks': + obj.locks.clear() + elif key == 'permissions': + obj.permissions.clear() + elif key == 'aliases': + obj.aliases.clear() + elif key == 'tags': + obj.tags.clear() + elif key == 'attrs': + obj.attributes.clear() + elif key == 'exec': + # we don't auto-rerun exec statements, it would be huge security risk! + pass + else: + obj.attributes.remove(key) + if do_save: + changed += 1 + obj.save() + + return changed + +def batch_create_object(*objparams): + """ + This is a cut-down version of the create_object() function, + optimized for speed. It does NOT check and convert various input + so make sure the spawned Typeclass works before using this! + + Args: + objsparams (tuple): Each paremter tuple will create one object instance using the parameters within. + The parameters should be given in the following order: + - `create_kwargs` (dict): For use as new_obj = `ObjectDB(**create_kwargs)`. + - `permissions` (str): Permission string used with `new_obj.batch_add(permission)`. + - `lockstring` (str): Lockstring used with `new_obj.locks.add(lockstring)`. + - `aliases` (list): A list of alias strings for + adding with `new_object.aliases.batch_add(*aliases)`. + - `nattributes` (list): list of tuples `(key, value)` to be loop-added to + add with `new_obj.nattributes.add(*tuple)`. + - `attributes` (list): list of tuples `(key, value[,category[,lockstring]])` for + adding with `new_obj.attributes.batch_add(*attributes)`. + - `tags` (list): list of tuples `(key, category)` for adding + with `new_obj.tags.batch_add(*tags)`. + - `execs` (list): Code strings to execute together with the creation + of each object. They will be executed with `evennia` and `obj` + (the newly created object) available in the namespace. Execution + will happend after all other properties have been assigned and + is intended for calling custom handlers etc. + + Returns: + objects (list): A list of created objects + + Notes: + The `exec` list will execute arbitrary python code so don't allow this to be available to + unprivileged users! + + """ + + # bulk create all objects in one go + + # unfortunately this doesn't work since bulk_create doesn't creates pks; + # the result would be duplicate objects at the next stage, so we comment + # it out for now: + # dbobjs = _ObjectDB.objects.bulk_create(dbobjs) + + dbobjs = [ObjectDB(**objparam[0]) for objparam in objparams] + objs = [] + for iobj, obj in enumerate(dbobjs): + # call all setup hooks on each object + objparam = objparams[iobj] + # setup + obj._createdict = {"permissions": make_iter(objparam[1]), + "locks": objparam[2], + "aliases": make_iter(objparam[3]), + "nattributes": objparam[4], + "attributes": objparam[5], + "tags": make_iter(objparam[6])} + # this triggers all hooks + obj.save() + # run eventual extra code + for code in objparam[7]: + if code: + exec(code, {}, {"evennia": evennia, "obj": obj}) + objs.append(obj) + return objs diff --git a/evennia/prototypes/spawner.py b/evennia/prototypes/spawner.py index 062e15ee92..15ef8afb4d 100644 --- a/evennia/prototypes/spawner.py +++ b/evennia/prototypes/spawner.py @@ -191,48 +191,6 @@ def validate_spawn_value(value, validator=None): # Spawner mechanism -def validate_prototype(prototype, protkey=None, protparents=None, _visited=None): - """ - Run validation on a prototype, checking for inifinite regress. - - Args: - prototype (dict): Prototype to validate. - protkey (str, optional): The name of the prototype definition. If not given, the prototype - dict needs to have the `prototype_key` field set. - protpartents (dict, optional): The available prototype parent library. If - note given this will be determined from settings/database. - _visited (list, optional): This is an internal work array and should not be set manually. - Raises: - RuntimeError: If prototype has invalid structure. - - """ - if not protparents: - protparents = get_protparent_dict() - if _visited is None: - _visited = [] - - protkey = protkey and protkey.lower() or prototype.get('prototype_key', None) - - assert isinstance(prototype, dict) - - if id(prototype) in _visited: - raise RuntimeError("%s has infinite nesting of prototypes." % protkey or prototype) - - _visited.append(id(prototype)) - protstrings = prototype.get("prototype") - - if protstrings: - for protstring in make_iter(protstrings): - protstring = protstring.lower() - if protkey is not None and protstring == protkey: - raise RuntimeError("%s tries to prototype itself." % protkey or prototype) - protparent = protparents.get(protstring) - if not protparent: - raise RuntimeError( - "%s's prototype '%s' was not found." % (protkey or prototype, protstring)) - validate_prototype(protparent, protstring, protparents, _visited) - - def _get_prototype(dic, prot, protparents): """ Recursively traverse a prototype dictionary, including multiple @@ -251,202 +209,6 @@ def _get_prototype(dic, prot, protparents): return prot -def prototype_diff_from_object(prototype, obj): - """ - Get a simple diff for a prototype compared to an object which may or may not already have a - prototype (or has one but changed locally). For more complex migratations a manual diff may be - needed. - - Args: - prototype (dict): Prototype. - obj (Object): Object to - - Returns: - diff (dict): Mapping for every prototype key: {"keyname": "REMOVE|UPDATE|KEEP", ...} - - """ - prot1 = prototype - prot2 = prototype_from_object(obj) - - diff = {} - for key, value in prot1.items(): - diff[key] = "KEEP" - if key in prot2: - if callable(prot2[key]) or value != prot2[key]: - diff[key] = "UPDATE" - elif key not in prot2: - diff[key] = "REMOVE" - - return diff - - -def batch_update_objects_with_prototype(prototype, diff=None, objects=None): - """ - Update existing objects with the latest version of the prototype. - - Args: - prototype (str or dict): Either the `prototype_key` to use or the - prototype dict itself. - diff (dict, optional): This a diff structure that describes how to update the protototype. - If not given this will be constructed from the first object found. - objects (list, optional): List of objects to update. If not given, query for these - objects using the prototype's `prototype_key`. - Returns: - changed (int): The number of objects that had changes applied to them. - - """ - prototype_key = prototype if isinstance(prototype, basestring) else prototype['prototype_key'] - prototype_obj = search_db_prototype(prototype_key, return_queryset=True) - prototype_obj = prototype_obj[0] if prototype_obj else None - new_prototype = prototype_obj.db.prototype - objs = ObjectDB.objects.get_by_tag(prototype_key, category=_PROTOTYPE_TAG_CATEGORY) - - if not objs: - return 0 - - if not diff: - diff = prototype_diff_from_object(new_prototype, objs[0]) - - changed = 0 - for obj in objs: - do_save = False - for key, directive in diff.items(): - val = new_prototype[key] - if directive in ('UPDATE', 'REPLACE'): - do_save = True - if key == 'key': - obj.db_key = validate_spawn_value(val, str) - elif key == 'typeclass': - obj.db_typeclass_path = validate_spawn_value(val, str) - elif key == 'location': - obj.db_location = validate_spawn_value(val, _to_obj) - elif key == 'home': - obj.db_home = validate_spawn_value(val, _to_obj) - elif key == 'destination': - obj.db_destination = validate_spawn_value(val, _to_obj) - elif key == 'locks': - if directive == 'REPLACE': - obj.locks.clear() - obj.locks.add(validate_spawn_value(val, str)) - elif key == 'permissions': - if directive == 'REPLACE': - obj.permissions.clear() - obj.permissions.batch_add(validate_spawn_value(val, make_iter)) - elif key == 'aliases': - if directive == 'REPLACE': - obj.aliases.clear() - obj.aliases.batch_add(validate_spawn_value(val, make_iter)) - elif key == 'tags': - if directive == 'REPLACE': - obj.tags.clear() - obj.tags.batch_add(validate_spawn_value(val, make_iter)) - elif key == 'attrs': - if directive == 'REPLACE': - obj.attributes.clear() - obj.attributes.batch_add(validate_spawn_value(val, make_iter)) - elif key == 'exec': - # we don't auto-rerun exec statements, it would be huge security risk! - pass - else: - obj.attributes.add(key, validate_spawn_value(val, _to_obj)) - elif directive == 'REMOVE': - do_save = True - if key == 'key': - obj.db_key = '' - elif key == 'typeclass': - # fall back to default - obj.db_typeclass_path = settings.BASE_OBJECT_TYPECLASS - elif key == 'location': - obj.db_location = None - elif key == 'home': - obj.db_home = None - elif key == 'destination': - obj.db_destination = None - elif key == 'locks': - obj.locks.clear() - elif key == 'permissions': - obj.permissions.clear() - elif key == 'aliases': - obj.aliases.clear() - elif key == 'tags': - obj.tags.clear() - elif key == 'attrs': - obj.attributes.clear() - elif key == 'exec': - # we don't auto-rerun exec statements, it would be huge security risk! - pass - else: - obj.attributes.remove(key) - if do_save: - changed += 1 - obj.save() - - return changed - - -def _batch_create_object(*objparams): - """ - This is a cut-down version of the create_object() function, - optimized for speed. It does NOT check and convert various input - so make sure the spawned Typeclass works before using this! - - Args: - objsparams (tuple): Each paremter tuple will create one object instance using the parameters within. - The parameters should be given in the following order: - - `create_kwargs` (dict): For use as new_obj = `ObjectDB(**create_kwargs)`. - - `permissions` (str): Permission string used with `new_obj.batch_add(permission)`. - - `lockstring` (str): Lockstring used with `new_obj.locks.add(lockstring)`. - - `aliases` (list): A list of alias strings for - adding with `new_object.aliases.batch_add(*aliases)`. - - `nattributes` (list): list of tuples `(key, value)` to be loop-added to - add with `new_obj.nattributes.add(*tuple)`. - - `attributes` (list): list of tuples `(key, value[,category[,lockstring]])` for - adding with `new_obj.attributes.batch_add(*attributes)`. - - `tags` (list): list of tuples `(key, category)` for adding - with `new_obj.tags.batch_add(*tags)`. - - `execs` (list): Code strings to execute together with the creation - of each object. They will be executed with `evennia` and `obj` - (the newly created object) available in the namespace. Execution - will happend after all other properties have been assigned and - is intended for calling custom handlers etc. - - Returns: - objects (list): A list of created objects - - Notes: - The `exec` list will execute arbitrary python code so don't allow this to be available to - unprivileged users! - - """ - - # bulk create all objects in one go - - # unfortunately this doesn't work since bulk_create doesn't creates pks; - # the result would be duplicate objects at the next stage, so we comment - # it out for now: - # dbobjs = _ObjectDB.objects.bulk_create(dbobjs) - - dbobjs = [ObjectDB(**objparam[0]) for objparam in objparams] - objs = [] - for iobj, obj in enumerate(dbobjs): - # call all setup hooks on each object - objparam = objparams[iobj] - # setup - obj._createdict = {"permissions": make_iter(objparam[1]), - "locks": objparam[2], - "aliases": make_iter(objparam[3]), - "nattributes": objparam[4], - "attributes": objparam[5], - "tags": make_iter(objparam[6])} - # this triggers all hooks - obj.save() - # run eventual extra code - for code in objparam[7]: - if code: - exec(code, {}, {"evennia": evennia, "obj": obj}) - objs.append(obj) - return objs - def spawn(*prototypes, **kwargs): """ @@ -472,7 +234,7 @@ def spawn(*prototypes, **kwargs): """ # get available protparents - protparents = get_protparent_dict() + protparents = {prot['prototype_key']: prot for prot in search_prototype()} # overload module's protparents with specifically given protparents protparents.update(kwargs.get("prototype_parents", {})) diff --git a/evennia/prototypes/utils.py b/evennia/prototypes/utils.py index 74eaef169f..6fe87d172c 100644 --- a/evennia/prototypes/utils.py +++ b/evennia/prototypes/utils.py @@ -4,91 +4,13 @@ Prototype utilities """ +_PROTOTYPE_META_NAMES = ("prototype_key", "prototype_desc", "prototype_tags", "prototype_locks") + class PermissionError(RuntimeError): pass - - - -def get_protparent_dict(): - """ - Get prototype parents. - - Returns: - parent_dict (dict): A mapping {prototype_key: prototype} for all available prototypes. - - """ - return {prototype['prototype_key']: prototype for prototype in search_prototype()} - - -def list_prototypes(caller, key=None, tags=None, show_non_use=False, show_non_edit=True): - """ - Collate a list of found prototypes based on search criteria and access. - - Args: - caller (Account or Object): The object requesting the list. - key (str, optional): Exact or partial prototype key to query for. - tags (str or list, optional): Tag key or keys to query for. - show_non_use (bool, optional): Show also prototypes the caller may not use. - show_non_edit (bool, optional): Show also prototypes the caller may not edit. - Returns: - table (EvTable or None): An EvTable representation of the prototypes. None - if no prototypes were found. - - """ - # this allows us to pass lists of empty strings - tags = [tag for tag in make_iter(tags) if tag] - - # get prototypes for readonly and db-based prototypes - prototypes = search_prototype(key, tags) - - # get use-permissions of readonly attributes (edit is always False) - display_tuples = [] - for prototype in sorted(prototypes, key=lambda d: d.get('prototype_key', '')): - lock_use = caller.locks.check_lockstring( - caller, prototype.get('prototype_locks', ''), access_type='use') - if not show_non_use and not lock_use: - continue - if prototype.get('prototype_key', '') in _MODULE_PROTOTYPES: - lock_edit = False - else: - lock_edit = caller.locks.check_lockstring( - caller, prototype.get('prototype_locks', ''), access_type='edit') - if not show_non_edit and not lock_edit: - continue - ptags = [] - for ptag in prototype.get('prototype_tags', []): - if is_iter(ptag): - if len(ptag) > 1: - ptags.append("{} (category: {}".format(ptag[0], ptag[1])) - else: - ptags.append(ptag[0]) - else: - ptags.append(str(ptag)) - - display_tuples.append( - (prototype.get('prototype_key', ''), - prototype.get('prototype_desc', ''), - "{}/{}".format('Y' if lock_use else 'N', 'Y' if lock_edit else 'N'), - ",".join(ptags))) - - if not display_tuples: - return None - - table = [] - width = 78 - for i in range(len(display_tuples[0])): - table.append([str(display_tuple[i]) for display_tuple in display_tuples]) - table = EvTable("Key", "Desc", "Use/Edit", "Tags", table=table, crop=True, width=width) - table.reformat_column(0, width=22) - table.reformat_column(1, width=29) - table.reformat_column(2, width=11, align='c') - table.reformat_column(3, width=16) - return table - - def prototype_to_str(prototype): """ Format a prototype to a nice string representation. @@ -111,40 +33,30 @@ def prototype_to_str(prototype): return header + proto -def prototype_from_object(obj): +def prototype_diff_from_object(prototype, obj): """ - Guess a minimal prototype from an existing object. + Get a simple diff for a prototype compared to an object which may or may not already have a + prototype (or has one but changed locally). For more complex migratations a manual diff may be + needed. Args: - obj (Object): An object to analyze. + prototype (dict): Prototype. + obj (Object): Object to Returns: - prototype (dict): A prototype estimating the current state of the object. + diff (dict): Mapping for every prototype key: {"keyname": "REMOVE|UPDATE|KEEP", ...} """ - # first, check if this object already has a prototype + prot1 = prototype + prot2 = prototype_from_object(obj) - prot = obj.tags.get(category=_PROTOTYPE_TAG_CATEGORY, return_list=True) - prot = search_prototype(prot) - if not prot or len(prot) > 1: - # no unambiguous prototype found - build new prototype - prot = {} - prot['prototype_key'] = "From-Object-{}-{}".format( - obj.key, hashlib.md5(str(time.time())).hexdigest()[:6]) - prot['prototype_desc'] = "Built from {}".format(str(obj)) - prot['prototype_locks'] = "use:all();edit:all()" + diff = {} + for key, value in prot1.items(): + diff[key] = "KEEP" + if key in prot2: + if callable(prot2[key]) or value != prot2[key]: + diff[key] = "UPDATE" + elif key not in prot2: + diff[key] = "REMOVE" - prot['key'] = obj.db_key or hashlib.md5(str(time.time())).hexdigest()[:6] - prot['location'] = obj.db_location - prot['home'] = obj.db_home - prot['destination'] = obj.db_destination - prot['typeclass'] = obj.db_typeclass_path - prot['locks'] = obj.locks.all() - prot['permissions'] = obj.permissions.get() - prot['aliases'] = obj.aliases.get() - prot['tags'] = [(tag.key, tag.category, tag.data) - for tag in obj.tags.get(return_tagobj=True, return_list=True)] - prot['attrs'] = [(attr.key, attr.value, attr.category, attr.locks) - for attr in obj.attributes.get(return_obj=True, return_list=True)] - - return prot + return diff From f864654f5eca43dca7acee89ab4f0475c9f9d1ec Mon Sep 17 00:00:00 2001 From: Griatch Date: Thu, 7 Jun 2018 22:40:03 +0200 Subject: [PATCH 133/208] Finish refactor prototypes/spawner/menus --- evennia/prototypes/menus.py | 45 ++-- evennia/prototypes/prototypes.py | 346 ++++++++++--------------------- evennia/prototypes/spawner.py | 335 +++++++++++++++++++++++------- evennia/prototypes/utils.py | 62 ------ 4 files changed, 399 insertions(+), 389 deletions(-) delete mode 100644 evennia/prototypes/utils.py diff --git a/evennia/prototypes/menus.py b/evennia/prototypes/menus.py index 85e7f3f574..bebc6d00bd 100644 --- a/evennia/prototypes/menus.py +++ b/evennia/prototypes/menus.py @@ -4,8 +4,13 @@ OLC Prototype menu nodes """ +from ast import literal_eval +from django.conf import settings from evennia.utils.evmenu import EvMenu, list_node from evennia.utils.ansi import strip_ansi +from evennia.utils import utils +from evennia.utils.prototypes import prototypes as protlib +from evennia.utils.prototypes import spawner # ------------------------------------------------------------ # @@ -13,6 +18,13 @@ from evennia.utils.ansi import strip_ansi # # ------------------------------------------------------------ +_MENU_CROP_WIDTH = 15 +_MENU_ATTR_LITERAL_EVAL_ERROR = ( + "|rCritical Python syntax error in your value. Only primitive Python structures are allowed.\n" + "You also need to use correct Python syntax. Remember especially to put quotes around all " + "strings inside lists and dicts.|n") + + # Helper functions @@ -48,11 +60,11 @@ def _format_property(prop, required=False, prototype=None, cropper=None): out = "<{}>".format(prop.__name__) else: out = repr(prop) - if is_iter(prop): + if utils.is_iter(prop): out = ", ".join(str(pr) for pr in prop) if not out and required: out = "|rrequired" - return " ({}|n)".format(cropper(out) if cropper else crop(out, _MENU_CROP_WIDTH)) + return " ({}|n)".format(cropper(out) if cropper else utils.crop(out, _MENU_CROP_WIDTH)) def _set_property(caller, raw_string, **kwargs): @@ -166,7 +178,8 @@ def node_index(caller): required = False for key in ('Desc', 'Tags', 'Locks'): options.append( - {"desc": "|WPrototype-{}|n|n{}".format(key, _format_property(key, required, prototype, None)), + {"desc": "|WPrototype-{}|n|n{}".format( + key, _format_property(key, required, prototype, None)), "goto": "node_prototype_{}".format(key.lower())}) return text, options @@ -175,11 +188,11 @@ def node_index(caller): def node_validate_prototype(caller, raw_string, **kwargs): prototype = _get_menu_prototype(caller) - txt = prototype_to_str(prototype) + txt = protlib.prototype_to_str(prototype) errors = "\n\n|g No validation errors found.|n (but errors could still happen at spawn-time)" try: # validate, don't spawn - spawn(prototype, return_prototypes=True) + spawner.spawn(prototype, return_prototypes=True) except RuntimeError as err: errors = "\n\n|rError: {}|n".format(err) text = (txt + errors) @@ -190,7 +203,7 @@ def node_validate_prototype(caller, raw_string, **kwargs): def _check_prototype_key(caller, key): - old_prototype = search_prototype(key) + old_prototype = protlib.search_prototype(key) olc_new = _is_new_prototype(caller) key = key.strip().lower() if old_prototype: @@ -231,13 +244,13 @@ def node_prototype_key(caller): def _all_prototypes(caller): return [prototype["prototype_key"] - for prototype in search_prototype() if "prototype_key" in prototype] + for prototype in protlib.search_prototype() if "prototype_key" in prototype] def _prototype_examine(caller, prototype_name): - prototypes = search_prototype(key=prototype_name) + prototypes = protlib.search_prototype(key=prototype_name) if prototypes: - caller.msg(prototype_to_str(prototypes[0])) + caller.msg(protlib.prototype_to_str(prototypes[0])) caller.msg("Prototype not registered.") return None @@ -256,9 +269,10 @@ def node_prototype(caller): text = ["Set the prototype's |yParent Prototype|n. If this is unset, Typeclass will be used."] if prot_parent_key: - prot_parent = search_prototype(prot_parent_key) + prot_parent = protlib.search_prototype(prot_parent_key) if prot_parent: - text.append("Current parent prototype is {}:\n{}".format(prototype_to_str(prot_parent))) + text.append( + "Current parent prototype is {}:\n{}".format(protlib.prototype_to_str(prot_parent))) else: text.append("Current parent prototype |r{prototype}|n " "does not appear to exist.".format(prot_parent_key)) @@ -273,7 +287,7 @@ def node_prototype(caller): def _all_typeclasses(caller): - return list(sorted(get_all_typeclasses().keys())) + return list(sorted(utils.get_all_typeclasses().keys())) def _typeclass_examine(caller, typeclass_path): @@ -281,7 +295,7 @@ def _typeclass_examine(caller, typeclass_path): # this means we are exiting the listing return "node_key" - typeclass = get_all_typeclasses().get(typeclass_path) + typeclass = utils.get_all_typeclasses().get(typeclass_path) if typeclass: docstr = [] for line in typeclass.__doc__.split("\n"): @@ -453,8 +467,8 @@ def _add_tag(caller, tag, **kwargs): tags.append(tag) else: tags = [tag] - prot['tags'] = tags - _set_menu_prototype(caller, "prototype", prot) + prototype['tags'] = tags + _set_menu_prototype(caller, "prototype", prototype) text = kwargs.get("text") if not text: text = "Added tag {}. (return to continue)".format(tag) @@ -706,4 +720,3 @@ def start_olc(caller, session=None, prototype=None): "node_prototype_locks": node_prototype_locks, } OLCMenu(caller, menudata, startnode='node_index', session=session, olc_prototype=prototype) - diff --git a/evennia/prototypes/prototypes.py b/evennia/prototypes/prototypes.py index e3d26fd87e..37fd83f846 100644 --- a/evennia/prototypes/prototypes.py +++ b/evennia/prototypes/prototypes.py @@ -6,16 +6,26 @@ Handling storage of prototypes, both database-based ones (DBPrototypes) and thos """ from django.conf import settings + from evennia.scripts.scripts import DefaultScript from evennia.objects.models import ObjectDB from evennia.utils.create import create_script -from evennia.utils.utils import all_from_module, make_iter, callables_from_module, is_iter +from evennia.utils.utils import ( + all_from_module, make_iter, is_iter, dbid_to_obj) from evennia.locks.lockhandler import validate_lockstring, check_lockstring from evennia.utils import logger +from evennia.utils.evtable import EvTable +from evennia.utils.prototypes.protfuncs import protfunc_parser _MODULE_PROTOTYPE_MODULES = {} _MODULE_PROTOTYPES = {} +_PROTOTYPE_META_NAMES = ("prototype_key", "prototype_desc", "prototype_tags", "prototype_locks") +_PROTOTYPE_TAG_CATEGORY = "spawned_by_prototype" + + +class PermissionError(RuntimeError): + pass class ValidationError(RuntimeError): @@ -25,6 +35,99 @@ class ValidationError(RuntimeError): pass +# helper functions + +def value_to_obj(value, force=True): + return dbid_to_obj(value, ObjectDB) + + +def value_to_obj_or_any(value): + obj = dbid_to_obj(value, ObjectDB) + return obj if obj is not None else value + + +def prototype_to_str(prototype): + """ + Format a prototype to a nice string representation. + + Args: + prototype (dict): The prototype. + """ + + header = ( + "|cprototype key:|n {}, |ctags:|n {}, |clocks:|n {} \n" + "|cdesc:|n {} \n|cprototype:|n ".format( + prototype['prototype_key'], + ", ".join(prototype['prototype_tags']), + prototype['prototype_locks'], + prototype['prototype_desc'])) + proto = ("{{\n {} \n}}".format( + "\n ".join( + "{!r}: {!r},".format(key, value) for key, value in + sorted(prototype.items()) if key not in _PROTOTYPE_META_NAMES)).rstrip(",")) + return header + proto + + +def check_permission(prototype_key, action, default=True): + """ + Helper function to check access to actions on given prototype. + + Args: + prototype_key (str): The prototype to affect. + action (str): One of "spawn" or "edit". + default (str): If action is unknown or prototype has no locks + + Returns: + passes (bool): If permission for action is granted or not. + + """ + if action == 'edit': + if prototype_key in _MODULE_PROTOTYPES: + mod = _MODULE_PROTOTYPE_MODULES.get(prototype_key, "N/A") + logger.log_err("{} is a read-only prototype " + "(defined as code in {}).".format(prototype_key, mod)) + return False + + prototype = search_prototype(key=prototype_key) + if not prototype: + logger.log_err("Prototype {} not found.".format(prototype_key)) + return False + + lockstring = prototype.get("prototype_locks") + + if lockstring: + return check_lockstring(None, lockstring, default=default, access_type=action) + return default + + +def init_spawn_value(value, validator=None): + """ + Analyze the prototype value and produce a value useful at the point of spawning. + + Args: + value (any): This can be: + callable - will be called as callable() + (callable, (args,)) - will be called as callable(*args) + other - will be assigned depending on the variable type + validator (callable, optional): If given, this will be called with the value to + check and guarantee the outcome is of a given type. + + Returns: + any (any): The (potentially pre-processed value to use for this prototype key) + + """ + value = protfunc_parser(value) + validator = validator if validator else lambda o: o + if callable(value): + return validator(value()) + elif value and is_iter(value) and callable(value[0]): + # a structure (callable, (args, )) + args = value[1:] + return validator(value[0](*make_iter(args))) + else: + return validator(value) + + # module-based prototypes for mod in settings.PROTOTYPE_MODULES: @@ -59,39 +162,7 @@ class DbPrototype(DefaultScript): self.db.prototype = {} # actual prototype -# General prototype functions - - -def check_permission(prototype_key, action, default=True): - """ - Helper function to check access to actions on given prototype. - - Args: - prototype_key (str): The prototype to affect. - action (str): One of "spawn" or "edit". - default (str): If action is unknown or prototype has no locks - - Returns: - passes (bool): If permission for action is granted or not. - - """ - if action == 'edit': - if prototype_key in _MODULE_PROTOTYPES: - mod = _MODULE_PROTOTYPE_MODULES.get(prototype_key, "N/A") - logger.log_err("{} is a read-only prototype " - "(defined as code in {}).".format(prototype_key, mod)) - return False - - prototype = search_prototype(key=prototype_key) - if not prototype: - logger.log_err("Prototype {} not found.".format(prototype_key)) - return False - - lockstring = prototype.get("prototype_locks") - - if lockstring: - return check_lockstring(None, lockstring, default=default, access_type=action) - return default +# Prototype manager functions def create_prototype(**kwargs): @@ -281,45 +352,6 @@ def search_objects_with_prototype(prototype_key): return ObjectDB.objects.get_by_tag(key=prototype_key, category=_PROTOTYPE_TAG_CATEGORY) -def prototype_from_object(obj): - """ - Guess a minimal prototype from an existing object. - - Args: - obj (Object): An object to analyze. - - Returns: - prototype (dict): A prototype estimating the current state of the object. - - """ - # first, check if this object already has a prototype - - prot = obj.tags.get(category=_PROTOTYPE_TAG_CATEGORY, return_list=True) - prot = search_prototype(prot) - if not prot or len(prot) > 1: - # no unambiguous prototype found - build new prototype - prot = {} - prot['prototype_key'] = "From-Object-{}-{}".format( - obj.key, hashlib.md5(str(time.time())).hexdigest()[:6]) - prot['prototype_desc'] = "Built from {}".format(str(obj)) - prot['prototype_locks'] = "spawn:all();edit:all()" - - prot['key'] = obj.db_key or hashlib.md5(str(time.time())).hexdigest()[:6] - prot['location'] = obj.db_location - prot['home'] = obj.db_home - prot['destination'] = obj.db_destination - prot['typeclass'] = obj.db_typeclass_path - prot['locks'] = obj.locks.all() - prot['permissions'] = obj.permissions.get() - prot['aliases'] = obj.aliases.get() - prot['tags'] = [(tag.key, tag.category, tag.data) - for tag in obj.tags.get(return_tagobj=True, return_list=True)] - prot['attrs'] = [(attr.key, attr.value, attr.category, attr.locks) - for attr in obj.attributes.get(return_obj=True, return_list=True)] - - return prot - - def list_prototypes(caller, key=None, tags=None, show_non_use=False, show_non_edit=True): """ Collate a list of found prototypes based on search criteria and access. @@ -384,171 +416,3 @@ def list_prototypes(caller, key=None, tags=None, show_non_use=False, show_non_ed table.reformat_column(2, width=11, align='c') table.reformat_column(3, width=16) return table - - - -def batch_update_objects_with_prototype(prototype, diff=None, objects=None): - """ - Update existing objects with the latest version of the prototype. - - Args: - prototype (str or dict): Either the `prototype_key` to use or the - prototype dict itself. - diff (dict, optional): This a diff structure that describes how to update the protototype. - If not given this will be constructed from the first object found. - objects (list, optional): List of objects to update. If not given, query for these - objects using the prototype's `prototype_key`. - Returns: - changed (int): The number of objects that had changes applied to them. - - """ - prototype_key = prototype if isinstance(prototype, basestring) else prototype['prototype_key'] - prototype_obj = search_db_prototype(prototype_key, return_queryset=True) - prototype_obj = prototype_obj[0] if prototype_obj else None - new_prototype = prototype_obj.db.prototype - objs = ObjectDB.objects.get_by_tag(prototype_key, category=_PROTOTYPE_TAG_CATEGORY) - - if not objs: - return 0 - - if not diff: - diff = prototype_diff_from_object(new_prototype, objs[0]) - - changed = 0 - for obj in objs: - do_save = False - for key, directive in diff.items(): - val = new_prototype[key] - if directive in ('UPDATE', 'REPLACE'): - do_save = True - if key == 'key': - obj.db_key = validate_spawn_value(val, str) - elif key == 'typeclass': - obj.db_typeclass_path = validate_spawn_value(val, str) - elif key == 'location': - obj.db_location = validate_spawn_value(val, _to_obj) - elif key == 'home': - obj.db_home = validate_spawn_value(val, _to_obj) - elif key == 'destination': - obj.db_destination = validate_spawn_value(val, _to_obj) - elif key == 'locks': - if directive == 'REPLACE': - obj.locks.clear() - obj.locks.add(validate_spawn_value(val, str)) - elif key == 'permissions': - if directive == 'REPLACE': - obj.permissions.clear() - obj.permissions.batch_add(validate_spawn_value(val, make_iter)) - elif key == 'aliases': - if directive == 'REPLACE': - obj.aliases.clear() - obj.aliases.batch_add(validate_spawn_value(val, make_iter)) - elif key == 'tags': - if directive == 'REPLACE': - obj.tags.clear() - obj.tags.batch_add(validate_spawn_value(val, make_iter)) - elif key == 'attrs': - if directive == 'REPLACE': - obj.attributes.clear() - obj.attributes.batch_add(validate_spawn_value(val, make_iter)) - elif key == 'exec': - # we don't auto-rerun exec statements, it would be huge security risk! - pass - else: - obj.attributes.add(key, validate_spawn_value(val, _to_obj)) - elif directive == 'REMOVE': - do_save = True - if key == 'key': - obj.db_key = '' - elif key == 'typeclass': - # fall back to default - obj.db_typeclass_path = settings.BASE_OBJECT_TYPECLASS - elif key == 'location': - obj.db_location = None - elif key == 'home': - obj.db_home = None - elif key == 'destination': - obj.db_destination = None - elif key == 'locks': - obj.locks.clear() - elif key == 'permissions': - obj.permissions.clear() - elif key == 'aliases': - obj.aliases.clear() - elif key == 'tags': - obj.tags.clear() - elif key == 'attrs': - obj.attributes.clear() - elif key == 'exec': - # we don't auto-rerun exec statements, it would be huge security risk! - pass - else: - obj.attributes.remove(key) - if do_save: - changed += 1 - obj.save() - - return changed - -def batch_create_object(*objparams): - """ - This is a cut-down version of the create_object() function, - optimized for speed. It does NOT check and convert various input - so make sure the spawned Typeclass works before using this! - - Args: - objsparams (tuple): Each paremter tuple will create one object instance using the parameters within. - The parameters should be given in the following order: - - `create_kwargs` (dict): For use as new_obj = `ObjectDB(**create_kwargs)`. - - `permissions` (str): Permission string used with `new_obj.batch_add(permission)`. - - `lockstring` (str): Lockstring used with `new_obj.locks.add(lockstring)`. - - `aliases` (list): A list of alias strings for - adding with `new_object.aliases.batch_add(*aliases)`. - - `nattributes` (list): list of tuples `(key, value)` to be loop-added to - add with `new_obj.nattributes.add(*tuple)`. - - `attributes` (list): list of tuples `(key, value[,category[,lockstring]])` for - adding with `new_obj.attributes.batch_add(*attributes)`. - - `tags` (list): list of tuples `(key, category)` for adding - with `new_obj.tags.batch_add(*tags)`. - - `execs` (list): Code strings to execute together with the creation - of each object. They will be executed with `evennia` and `obj` - (the newly created object) available in the namespace. Execution - will happend after all other properties have been assigned and - is intended for calling custom handlers etc. - - Returns: - objects (list): A list of created objects - - Notes: - The `exec` list will execute arbitrary python code so don't allow this to be available to - unprivileged users! - - """ - - # bulk create all objects in one go - - # unfortunately this doesn't work since bulk_create doesn't creates pks; - # the result would be duplicate objects at the next stage, so we comment - # it out for now: - # dbobjs = _ObjectDB.objects.bulk_create(dbobjs) - - dbobjs = [ObjectDB(**objparam[0]) for objparam in objparams] - objs = [] - for iobj, obj in enumerate(dbobjs): - # call all setup hooks on each object - objparam = objparams[iobj] - # setup - obj._createdict = {"permissions": make_iter(objparam[1]), - "locks": objparam[2], - "aliases": make_iter(objparam[3]), - "nattributes": objparam[4], - "attributes": objparam[5], - "tags": make_iter(objparam[6])} - # this triggers all hooks - obj.save() - # run eventual extra code - for code in objparam[7]: - if code: - exec(code, {}, {"evennia": evennia, "obj": obj}) - objs.append(obj) - return objs diff --git a/evennia/prototypes/spawner.py b/evennia/prototypes/spawner.py index 15ef8afb4d..995cea6e52 100644 --- a/evennia/prototypes/spawner.py +++ b/evennia/prototypes/spawner.py @@ -126,70 +126,25 @@ from __future__ import print_function import copy import hashlib import time -from ast import literal_eval + from django.conf import settings -from random import randint import evennia +from random import randint from evennia.objects.models import ObjectDB from evennia.utils.utils import ( make_iter, dbid_to_obj, - is_iter, crop, get_all_typeclasses) - -from evennia.utils.evtable import EvTable + is_iter, get_all_typeclasses) +from evennia.prototypes import prototypes as protlib +from evennia.prototypes.prototypes import value_to_obj, value_to_obj_or_any, init_spawn_value _CREATE_OBJECT_KWARGS = ("key", "location", "home", "destination") _PROTOTYPE_META_NAMES = ("prototype_key", "prototype_desc", "prototype_tags", "prototype_locks") _NON_CREATE_KWARGS = _CREATE_OBJECT_KWARGS + _PROTOTYPE_META_NAMES -_MENU_CROP_WIDTH = 15 _PROTOTYPE_TAG_CATEGORY = "spawned_by_prototype" -_MENU_ATTR_LITERAL_EVAL_ERROR = ( - "|rCritical Python syntax error in your value. Only primitive Python structures are allowed.\n" - "You also need to use correct Python syntax. Remember especially to put quotes around all " - "strings inside lists and dicts.|n") - - -# Helper functions - -def _to_obj(value, force=True): - return dbid_to_obj(value, ObjectDB) - - -def _to_obj_or_any(value): - obj = dbid_to_obj(value, ObjectDB) - return obj if obj is not None else value - - -def validate_spawn_value(value, validator=None): - """ - Analyze the value and produce a value for use at the point of spawning. - - Args: - value (any): This can be: - callable - will be called as callable() - (callable, (args,)) - will be called as callable(*args) - other - will be assigned depending on the variable type - validator (callable, optional): If given, this will be called with the value to - check and guarantee the outcome is of a given type. - - Returns: - any (any): The (potentially pre-processed value to use for this prototype key) - - """ - value = protfunc_parser(value) - validator = validator if validator else lambda o: o - if callable(value): - return validator(value()) - elif value and is_iter(value) and callable(value[0]): - # a structure (callable, (args, )) - args = value[1:] - return validator(value[0](*make_iter(args))) - else: - return validator(value) - -# Spawner mechanism +# Helper def _get_prototype(dic, prot, protparents): """ @@ -209,6 +164,246 @@ def _get_prototype(dic, prot, protparents): return prot +# obj-related prototype functions + +def prototype_from_object(obj): + """ + Guess a minimal prototype from an existing object. + + Args: + obj (Object): An object to analyze. + + Returns: + prototype (dict): A prototype estimating the current state of the object. + + """ + # first, check if this object already has a prototype + + prot = obj.tags.get(category=_PROTOTYPE_TAG_CATEGORY, return_list=True) + prot = protlib.search_prototype(prot) + if not prot or len(prot) > 1: + # no unambiguous prototype found - build new prototype + prot = {} + prot['prototype_key'] = "From-Object-{}-{}".format( + obj.key, hashlib.md5(str(time.time())).hexdigest()[:6]) + prot['prototype_desc'] = "Built from {}".format(str(obj)) + prot['prototype_locks'] = "spawn:all();edit:all()" + + prot['key'] = obj.db_key or hashlib.md5(str(time.time())).hexdigest()[:6] + prot['location'] = obj.db_location + prot['home'] = obj.db_home + prot['destination'] = obj.db_destination + prot['typeclass'] = obj.db_typeclass_path + prot['locks'] = obj.locks.all() + prot['permissions'] = obj.permissions.get() + prot['aliases'] = obj.aliases.get() + prot['tags'] = [(tag.key, tag.category, tag.data) + for tag in obj.tags.get(return_tagobj=True, return_list=True)] + prot['attrs'] = [(attr.key, attr.value, attr.category, attr.locks) + for attr in obj.attributes.get(return_obj=True, return_list=True)] + + return prot + + +def prototype_diff_from_object(prototype, obj): + """ + Get a simple diff for a prototype compared to an object which may or may not already have a + prototype (or has one but changed locally). For more complex migratations a manual diff may be + needed. + + Args: + prototype (dict): Prototype. + obj (Object): Object to + + Returns: + diff (dict): Mapping for every prototype key: {"keyname": "REMOVE|UPDATE|KEEP", ...} + + """ + prot1 = prototype + prot2 = prototype_from_object(obj) + + diff = {} + for key, value in prot1.items(): + diff[key] = "KEEP" + if key in prot2: + if callable(prot2[key]) or value != prot2[key]: + diff[key] = "UPDATE" + elif key not in prot2: + diff[key] = "REMOVE" + + return diff + + +def batch_update_objects_with_prototype(prototype, diff=None, objects=None): + """ + Update existing objects with the latest version of the prototype. + + Args: + prototype (str or dict): Either the `prototype_key` to use or the + prototype dict itself. + diff (dict, optional): This a diff structure that describes how to update the protototype. + If not given this will be constructed from the first object found. + objects (list, optional): List of objects to update. If not given, query for these + objects using the prototype's `prototype_key`. + Returns: + changed (int): The number of objects that had changes applied to them. + + """ + prototype_key = prototype if isinstance(prototype, basestring) else prototype['prototype_key'] + prototype_obj = protlib.DbPrototype.objects.filter(db_key=prototype_key) + prototype_obj = prototype_obj[0] if prototype_obj else None + new_prototype = prototype_obj.db.prototype + objs = ObjectDB.objects.get_by_tag(prototype_key, category=_PROTOTYPE_TAG_CATEGORY) + + if not objs: + return 0 + + if not diff: + diff = prototype_diff_from_object(new_prototype, objs[0]) + + changed = 0 + for obj in objs: + do_save = False + for key, directive in diff.items(): + val = new_prototype[key] + if directive in ('UPDATE', 'REPLACE'): + do_save = True + if key == 'key': + obj.db_key = init_spawn_value(val, str) + elif key == 'typeclass': + obj.db_typeclass_path = init_spawn_value(val, str) + elif key == 'location': + obj.db_location = init_spawn_value(val, value_to_obj) + elif key == 'home': + obj.db_home = init_spawn_value(val, value_to_obj) + elif key == 'destination': + obj.db_destination = init_spawn_value(val, value_to_obj) + elif key == 'locks': + if directive == 'REPLACE': + obj.locks.clear() + obj.locks.add(init_spawn_value(val, str)) + elif key == 'permissions': + if directive == 'REPLACE': + obj.permissions.clear() + obj.permissions.batch_add(init_spawn_value(val, make_iter)) + elif key == 'aliases': + if directive == 'REPLACE': + obj.aliases.clear() + obj.aliases.batch_add(init_spawn_value(val, make_iter)) + elif key == 'tags': + if directive == 'REPLACE': + obj.tags.clear() + obj.tags.batch_add(init_spawn_value(val, make_iter)) + elif key == 'attrs': + if directive == 'REPLACE': + obj.attributes.clear() + obj.attributes.batch_add(init_spawn_value(val, make_iter)) + elif key == 'exec': + # we don't auto-rerun exec statements, it would be huge security risk! + pass + else: + obj.attributes.add(key, init_spawn_value(val, value_to_obj)) + elif directive == 'REMOVE': + do_save = True + if key == 'key': + obj.db_key = '' + elif key == 'typeclass': + # fall back to default + obj.db_typeclass_path = settings.BASE_OBJECT_TYPECLASS + elif key == 'location': + obj.db_location = None + elif key == 'home': + obj.db_home = None + elif key == 'destination': + obj.db_destination = None + elif key == 'locks': + obj.locks.clear() + elif key == 'permissions': + obj.permissions.clear() + elif key == 'aliases': + obj.aliases.clear() + elif key == 'tags': + obj.tags.clear() + elif key == 'attrs': + obj.attributes.clear() + elif key == 'exec': + # we don't auto-rerun exec statements, it would be huge security risk! + pass + else: + obj.attributes.remove(key) + if do_save: + changed += 1 + obj.save() + + return changed + + +def batch_create_object(*objparams): + """ + This is a cut-down version of the create_object() function, + optimized for speed. It does NOT check and convert various input + so make sure the spawned Typeclass works before using this! + + Args: + objsparams (tuple): Each paremter tuple will create one object instance using the parameters + within. + The parameters should be given in the following order: + - `create_kwargs` (dict): For use as new_obj = `ObjectDB(**create_kwargs)`. + - `permissions` (str): Permission string used with `new_obj.batch_add(permission)`. + - `lockstring` (str): Lockstring used with `new_obj.locks.add(lockstring)`. + - `aliases` (list): A list of alias strings for + adding with `new_object.aliases.batch_add(*aliases)`. + - `nattributes` (list): list of tuples `(key, value)` to be loop-added to + add with `new_obj.nattributes.add(*tuple)`. + - `attributes` (list): list of tuples `(key, value[,category[,lockstring]])` for + adding with `new_obj.attributes.batch_add(*attributes)`. + - `tags` (list): list of tuples `(key, category)` for adding + with `new_obj.tags.batch_add(*tags)`. + - `execs` (list): Code strings to execute together with the creation + of each object. They will be executed with `evennia` and `obj` + (the newly created object) available in the namespace. Execution + will happend after all other properties have been assigned and + is intended for calling custom handlers etc. + + Returns: + objects (list): A list of created objects + + Notes: + The `exec` list will execute arbitrary python code so don't allow this to be available to + unprivileged users! + + """ + + # bulk create all objects in one go + + # unfortunately this doesn't work since bulk_create doesn't creates pks; + # the result would be duplicate objects at the next stage, so we comment + # it out for now: + # dbobjs = _ObjectDB.objects.bulk_create(dbobjs) + + dbobjs = [ObjectDB(**objparam[0]) for objparam in objparams] + objs = [] + for iobj, obj in enumerate(dbobjs): + # call all setup hooks on each object + objparam = objparams[iobj] + # setup + obj._createdict = {"permissions": make_iter(objparam[1]), + "locks": objparam[2], + "aliases": make_iter(objparam[3]), + "nattributes": objparam[4], + "attributes": objparam[5], + "tags": make_iter(objparam[6])} + # this triggers all hooks + obj.save() + # run eventual extra code + for code in objparam[7]: + if code: + exec(code, {}, {"evennia": evennia, "obj": obj}) + objs.append(obj) + return objs + + +# Spawner mechanism def spawn(*prototypes, **kwargs): """ @@ -234,12 +429,12 @@ def spawn(*prototypes, **kwargs): """ # get available protparents - protparents = {prot['prototype_key']: prot for prot in search_prototype()} + protparents = {prot['prototype_key']: prot for prot in protlib.search_prototype()} # overload module's protparents with specifically given protparents protparents.update(kwargs.get("prototype_parents", {})) for key, prototype in protparents.items(): - validate_prototype(prototype, key.lower(), protparents) + protlib.validate_prototype(prototype, key.lower(), protparents) if "return_prototypes" in kwargs: # only return the parents @@ -248,7 +443,7 @@ def spawn(*prototypes, **kwargs): objsparams = [] for prototype in prototypes: - validate_prototype(prototype, None, protparents) + protlib.validate_prototype(prototype, None, protparents) prot = _get_prototype(prototype, {}, protparents) if not prot: continue @@ -260,30 +455,30 @@ def spawn(*prototypes, **kwargs): # chance this is not unique but it should usually not be a problem. val = prot.pop("key", "Spawned-{}".format( hashlib.md5(str(time.time())).hexdigest()[:6])) - create_kwargs["db_key"] = validate_spawn_value(val, str) + create_kwargs["db_key"] = init_spawn_value(val, str) val = prot.pop("location", None) - create_kwargs["db_location"] = validate_spawn_value(val, _to_obj) + create_kwargs["db_location"] = init_spawn_value(val, value_to_obj) val = prot.pop("home", settings.DEFAULT_HOME) - create_kwargs["db_home"] = validate_spawn_value(val, _to_obj) + create_kwargs["db_home"] = init_spawn_value(val, value_to_obj) val = prot.pop("destination", None) - create_kwargs["db_destination"] = validate_spawn_value(val, _to_obj) + create_kwargs["db_destination"] = init_spawn_value(val, value_to_obj) val = prot.pop("typeclass", settings.BASE_OBJECT_TYPECLASS) - create_kwargs["db_typeclass_path"] = validate_spawn_value(val, str) + create_kwargs["db_typeclass_path"] = init_spawn_value(val, str) # extract calls to handlers val = prot.pop("permissions", []) - permission_string = validate_spawn_value(val, make_iter) + permission_string = init_spawn_value(val, make_iter) val = prot.pop("locks", "") - lock_string = validate_spawn_value(val, str) + lock_string = init_spawn_value(val, str) val = prot.pop("aliases", []) - alias_string = validate_spawn_value(val, make_iter) + alias_string = init_spawn_value(val, make_iter) val = prot.pop("tags", []) - tags = validate_spawn_value(val, make_iter) + tags = init_spawn_value(val, make_iter) prototype_key = prototype.get('prototype_key', None) if prototype_key: @@ -291,15 +486,15 @@ def spawn(*prototypes, **kwargs): tags.append((prototype_key, _PROTOTYPE_TAG_CATEGORY)) val = prot.pop("exec", "") - execs = validate_spawn_value(val, make_iter) + execs = init_spawn_value(val, make_iter) # extract ndb assignments - nattribute = dict((key.split("_", 1)[1], validate_spawn_value(val, _to_obj)) + nattributes = dict((key.split("_", 1)[1], init_spawn_value(val, value_to_obj)) for key, val in prot.items() if key.startswith("ndb_")) # the rest are attributes val = prot.pop("attrs", []) - attributes = validate_spawn_value(val, list) + attributes = init_spawn_value(val, list) simple_attributes = [] for key, value in ((key, value) for key, value in prot.items() @@ -307,11 +502,11 @@ def spawn(*prototypes, **kwargs): if is_iter(value) and len(value) > 1: # (value, category) simple_attributes.append((key, - validate_spawn_value(value[0], _to_obj_or_any), - validate_spawn_value(value[1], str))) + init_spawn_value(value[0], value_to_obj_or_any), + init_spawn_value(value[1], str))) else: simple_attributes.append((key, - validate_spawn_value(value, _to_obj_or_any))) + init_spawn_value(value, value_to_obj_or_any))) attributes = attributes + simple_attributes attributes = [tup for tup in attributes if not tup[0] in _NON_CREATE_KWARGS] @@ -320,7 +515,7 @@ def spawn(*prototypes, **kwargs): objsparams.append((create_kwargs, permission_string, lock_string, alias_string, nattributes, attributes, tags, execs)) - return _batch_create_object(*objsparams) + return batch_create_object(*objsparams) # Testing diff --git a/evennia/prototypes/utils.py b/evennia/prototypes/utils.py deleted file mode 100644 index 6fe87d172c..0000000000 --- a/evennia/prototypes/utils.py +++ /dev/null @@ -1,62 +0,0 @@ -""" - -Prototype utilities - -""" - -_PROTOTYPE_META_NAMES = ("prototype_key", "prototype_desc", "prototype_tags", "prototype_locks") - - -class PermissionError(RuntimeError): - pass - - -def prototype_to_str(prototype): - """ - Format a prototype to a nice string representation. - - Args: - prototype (dict): The prototype. - """ - - header = ( - "|cprototype key:|n {}, |ctags:|n {}, |clocks:|n {} \n" - "|cdesc:|n {} \n|cprototype:|n ".format( - prototype['prototype_key'], - ", ".join(prototype['prototype_tags']), - prototype['prototype_locks'], - prototype['prototype_desc'])) - proto = ("{{\n {} \n}}".format( - "\n ".join( - "{!r}: {!r},".format(key, value) for key, value in - sorted(prototype.items()) if key not in _PROTOTYPE_META_NAMES)).rstrip(",")) - return header + proto - - -def prototype_diff_from_object(prototype, obj): - """ - Get a simple diff for a prototype compared to an object which may or may not already have a - prototype (or has one but changed locally). For more complex migratations a manual diff may be - needed. - - Args: - prototype (dict): Prototype. - obj (Object): Object to - - Returns: - diff (dict): Mapping for every prototype key: {"keyname": "REMOVE|UPDATE|KEEP", ...} - - """ - prot1 = prototype - prot2 = prototype_from_object(obj) - - diff = {} - for key, value in prot1.items(): - diff[key] = "KEEP" - if key in prot2: - if callable(prot2[key]) or value != prot2[key]: - diff[key] = "UPDATE" - elif key not in prot2: - diff[key] = "REMOVE" - - return diff From 7bf2cd4c0eef91137fcb3ddf1dc7b56de9f4d5c2 Mon Sep 17 00:00:00 2001 From: Griatch Date: Sat, 9 Jun 2018 12:10:32 +0200 Subject: [PATCH 134/208] Move spawner tests into prototypes folder --- evennia/__init__.py | 2 +- evennia/commands/default/building.py | 2 +- evennia/prototypes/prototypes.py | 2 +- evennia/prototypes/spawner.py | 44 +------------------ .../test_spawner.py => prototypes/tests.py} | 41 ++++++++++++++++- 5 files changed, 43 insertions(+), 48 deletions(-) rename evennia/{utils/tests/test_spawner.py => prototypes/tests.py} (74%) diff --git a/evennia/__init__.py b/evennia/__init__.py index 6fdc4aaece..fc916351ad 100644 --- a/evennia/__init__.py +++ b/evennia/__init__.py @@ -174,7 +174,7 @@ def _init(): from .utils import logger from .utils import gametime from .utils import ansi - from .utils.spawner import spawn + from .prototypes.spawner import spawn from . import contrib from .utils.evmenu import EvMenu from .utils.evtable import EvTable diff --git a/evennia/commands/default/building.py b/evennia/commands/default/building.py index 4aabc861b1..692dd2aac6 100644 --- a/evennia/commands/default/building.py +++ b/evennia/commands/default/building.py @@ -13,7 +13,7 @@ from evennia.utils import create, utils, search from evennia.utils.utils import inherits_from, class_from_module, get_all_typeclasses from evennia.utils.eveditor import EvEditor from evennia.utils.evmore import EvMore -from evennia.utils import spawner +from evennia.prototypes import spawner from evennia.utils.ansi import raw COMMAND_DEFAULT_CLASS = class_from_module(settings.COMMAND_DEFAULT_CLASS) diff --git a/evennia/prototypes/prototypes.py b/evennia/prototypes/prototypes.py index 37fd83f846..0020f807c1 100644 --- a/evennia/prototypes/prototypes.py +++ b/evennia/prototypes/prototypes.py @@ -15,7 +15,7 @@ from evennia.utils.utils import ( from evennia.locks.lockhandler import validate_lockstring, check_lockstring from evennia.utils import logger from evennia.utils.evtable import EvTable -from evennia.utils.prototypes.protfuncs import protfunc_parser +from evennia.prototypes.protfuncs import protfunc_parser _MODULE_PROTOTYPE_MODULES = {} diff --git a/evennia/prototypes/spawner.py b/evennia/prototypes/spawner.py index 995cea6e52..8cadd43656 100644 --- a/evennia/prototypes/spawner.py +++ b/evennia/prototypes/spawner.py @@ -129,11 +129,8 @@ import time from django.conf import settings import evennia -from random import randint from evennia.objects.models import ObjectDB -from evennia.utils.utils import ( - make_iter, dbid_to_obj, - is_iter, get_all_typeclasses) +from evennia.utils.utils import make_iter, is_iter from evennia.prototypes import prototypes as protlib from evennia.prototypes.prototypes import value_to_obj, value_to_obj_or_any, init_spawn_value @@ -516,42 +513,3 @@ def spawn(*prototypes, **kwargs): alias_string, nattributes, attributes, tags, execs)) return batch_create_object(*objsparams) - - -# Testing - -if __name__ == "__main__": - protparents = { - "NOBODY": {}, - # "INFINITE" : { - # "prototype":"INFINITE" - # }, - "GOBLIN": { - "key": "goblin grunt", - "health": lambda: randint(20, 30), - "resists": ["cold", "poison"], - "attacks": ["fists"], - "weaknesses": ["fire", "light"] - }, - "GOBLIN_WIZARD": { - "prototype": "GOBLIN", - "key": "goblin wizard", - "spells": ["fire ball", "lighting bolt"] - }, - "GOBLIN_ARCHER": { - "prototype": "GOBLIN", - "key": "goblin archer", - "attacks": ["short bow"] - }, - "ARCHWIZARD": { - "attacks": ["archwizard staff"], - }, - "GOBLIN_ARCHWIZARD": { - "key": "goblin archwizard", - "prototype": ("GOBLIN_WIZARD", "ARCHWIZARD") - } - } - # test - print([o.key for o in spawn(protparents["GOBLIN"], - protparents["GOBLIN_ARCHWIZARD"], - prototype_parents=protparents)]) diff --git a/evennia/utils/tests/test_spawner.py b/evennia/prototypes/tests.py similarity index 74% rename from evennia/utils/tests/test_spawner.py rename to evennia/prototypes/tests.py index 4d680a9e8a..1b8e340377 100644 --- a/evennia/utils/tests/test_spawner.py +++ b/evennia/prototypes/tests.py @@ -1,10 +1,44 @@ """ -Unit test for the spawner +Unit tests for the prototypes and spawner """ +from random import randint from evennia.utils.test_resources import EvenniaTest -from evennia.utils import spawner +from evennia.prototypes import spawner, prototypes as protlib + + +_PROTPARENTS = { + "NOBODY": {}, + "GOBLIN": { + "key": "goblin grunt", + "health": lambda: randint(1, 1), + "resists": ["cold", "poison"], + "attacks": ["fists"], + "weaknesses": ["fire", "light"] + }, + "GOBLIN_WIZARD": { + "prototype": "GOBLIN", + "key": "goblin wizard", + "spells": ["fire ball", "lighting bolt"] + }, + "GOBLIN_ARCHER": { + "prototype": "GOBLIN", + "key": "goblin archer", + "attacks": ["short bow"] + }, + "ARCHWIZARD": { + "attacks": ["archwizard staff"], + }, + "GOBLIN_ARCHWIZARD": { + "key": "goblin archwizard", + "prototype": ("GOBLIN_WIZARD", "ARCHWIZARD") + } +} + + +class TestPrototypes(EvenniaTest): + pass class TestSpawner(EvenniaTest): @@ -17,6 +51,9 @@ class TestSpawner(EvenniaTest): obj1 = spawner.spawn(self.prot1) # check spawned objects have the right tag self.assertEqual(list(spawner.search_objects_with_prototype("testprototype")), obj1) + self.assertEqual([o.key for o in spawner.spawn( + _PROTPARENTS["GOBLIN"], _PROTPARENTS["GOBLIN_ARCHWIZARD"], + prototype_parents=_PROTPARENTS)], []) class TestPrototypeStorage(EvenniaTest): From d4963ab36b1d4528787f5aaa999b73cd225f320b Mon Sep 17 00:00:00 2001 From: Griatch Date: Sat, 9 Jun 2018 23:57:46 +0200 Subject: [PATCH 135/208] Start adding unittests for prototypes --- evennia/prototypes/prototypes.py | 42 ++++++++++++++++++++++++++++++++ evennia/prototypes/spawner.py | 6 +++-- evennia/prototypes/tests.py | 4 +-- 3 files changed, 48 insertions(+), 4 deletions(-) diff --git a/evennia/prototypes/prototypes.py b/evennia/prototypes/prototypes.py index 0020f807c1..bb917a8dc4 100644 --- a/evennia/prototypes/prototypes.py +++ b/evennia/prototypes/prototypes.py @@ -416,3 +416,45 @@ def list_prototypes(caller, key=None, tags=None, show_non_use=False, show_non_ed table.reformat_column(2, width=11, align='c') table.reformat_column(3, width=16) return table + + +def validate_prototype(prototype, protkey=None, protparents=None, _visited=None): + """ + Run validation on a prototype, checking for inifinite regress. + + Args: + prototype (dict): Prototype to validate. + protkey (str, optional): The name of the prototype definition. If not given, the prototype + dict needs to have the `prototype_key` field set. + protpartents (dict, optional): The available prototype parent library. If + note given this will be determined from settings/database. + _visited (list, optional): This is an internal work array and should not be set manually. + Raises: + RuntimeError: If prototype has invalid structure. + + """ + if not protparents: + protparents = {prototype['prototype_key']: prototype for prototype in search_prototype()} + if _visited is None: + _visited = [] + + protkey = protkey and protkey.lower() or prototype.get('prototype_key', None) + + assert isinstance(prototype, dict) + + if id(prototype) in _visited: + raise RuntimeError("%s has infinite nesting of prototypes." % protkey or prototype) + + _visited.append(id(prototype)) + protstrings = prototype.get("prototype") + + if protstrings: + for protstring in make_iter(protstrings): + protstring = protstring.lower() + if protkey is not None and protstring == protkey: + raise RuntimeError("%s tries to prototype itself." % protkey or prototype) + protparent = protparents.get(protstring) + if not protparent: + raise RuntimeError( + "%s's prototype '%s' was not found." % (protkey or prototype, protstring)) + validate_prototype(protparent, protstring, protparents, _visited) diff --git a/evennia/prototypes/spawner.py b/evennia/prototypes/spawner.py index 8cadd43656..5a1196513a 100644 --- a/evennia/prototypes/spawner.py +++ b/evennia/prototypes/spawner.py @@ -426,10 +426,12 @@ def spawn(*prototypes, **kwargs): """ # get available protparents - protparents = {prot['prototype_key']: prot for prot in protlib.search_prototype()} + protparents = {prot['prototype_key'].lower(): prot for prot in protlib.search_prototype()} # overload module's protparents with specifically given protparents - protparents.update(kwargs.get("prototype_parents", {})) + protparents.update( + {key.lower(): value for key, value in kwargs.get("prototype_parents", {}).items()}) + for key, prototype in protparents.items(): protlib.validate_prototype(prototype, key.lower(), protparents) diff --git a/evennia/prototypes/tests.py b/evennia/prototypes/tests.py index 1b8e340377..e9ef4bce9f 100644 --- a/evennia/prototypes/tests.py +++ b/evennia/prototypes/tests.py @@ -50,10 +50,10 @@ class TestSpawner(EvenniaTest): def test_spawn(self): obj1 = spawner.spawn(self.prot1) # check spawned objects have the right tag - self.assertEqual(list(spawner.search_objects_with_prototype("testprototype")), obj1) + self.assertEqual(list(protlib.search_objects_with_prototype("testprototype")), obj1) self.assertEqual([o.key for o in spawner.spawn( _PROTPARENTS["GOBLIN"], _PROTPARENTS["GOBLIN_ARCHWIZARD"], - prototype_parents=_PROTPARENTS)], []) + prototype_parents=_PROTPARENTS)], ['goblin grunt', 'goblin archwizard']) class TestPrototypeStorage(EvenniaTest): From fe26ffac5f0b967f3be3f205329801b8d6138646 Mon Sep 17 00:00:00 2001 From: Griatch Date: Sun, 10 Jun 2018 14:27:34 +0200 Subject: [PATCH 136/208] Add unittests, fix bugs --- evennia/prototypes/spawner.py | 99 +++++++++++++++++++++++++---------- evennia/prototypes/tests.py | 95 +++++++++++++++++++++++++++++++++ requirements.txt | 4 +- 3 files changed, 168 insertions(+), 30 deletions(-) diff --git a/evennia/prototypes/spawner.py b/evennia/prototypes/spawner.py index 5a1196513a..f34fe8c854 100644 --- a/evennia/prototypes/spawner.py +++ b/evennia/prototypes/spawner.py @@ -177,27 +177,45 @@ def prototype_from_object(obj): # first, check if this object already has a prototype prot = obj.tags.get(category=_PROTOTYPE_TAG_CATEGORY, return_list=True) - prot = protlib.search_prototype(prot) + if prot: + prot = protlib.search_prototype(prot[0]) if not prot or len(prot) > 1: # no unambiguous prototype found - build new prototype prot = {} prot['prototype_key'] = "From-Object-{}-{}".format( - obj.key, hashlib.md5(str(time.time())).hexdigest()[:6]) + obj.key, hashlib.md5(str(time.time())).hexdigest()[:7]) prot['prototype_desc'] = "Built from {}".format(str(obj)) prot['prototype_locks'] = "spawn:all();edit:all()" prot['key'] = obj.db_key or hashlib.md5(str(time.time())).hexdigest()[:6] - prot['location'] = obj.db_location - prot['home'] = obj.db_home - prot['destination'] = obj.db_destination prot['typeclass'] = obj.db_typeclass_path - prot['locks'] = obj.locks.all() - prot['permissions'] = obj.permissions.get() - prot['aliases'] = obj.aliases.get() - prot['tags'] = [(tag.key, tag.category, tag.data) - for tag in obj.tags.get(return_tagobj=True, return_list=True)] - prot['attrs'] = [(attr.key, attr.value, attr.category, attr.locks) - for attr in obj.attributes.get(return_obj=True, return_list=True)] + + location = obj.db_location + if location: + prot['location'] = location + home = obj.db_home + if home: + prot['home'] = home + destination = obj.db_destination + if destination: + prot['destination'] = destination + locks = obj.locks.all() + if locks: + prot['locks'] = locks + perms = obj.permissions.get() + if perms: + prot['permissions'] = perms + aliases = obj.aliases.get() + if aliases: + prot['aliases'] = aliases + tags = [(tag.db_key, tag.db_category, tag.db_data) + for tag in obj.tags.get(return_tagobj=True, return_list=True) if tag] + if tags: + prot['tags'] = tags + attrs = [(attr.key, attr.value, attr.category, attr.locks.all()) + for attr in obj.attributes.get(return_obj=True, return_list=True) if attr] + if attrs: + prot['attrs'] = attrs return prot @@ -224,8 +242,14 @@ def prototype_diff_from_object(prototype, obj): diff[key] = "KEEP" if key in prot2: if callable(prot2[key]) or value != prot2[key]: - diff[key] = "UPDATE" + if key in ('attrs', 'tags', 'permissions', 'locks', 'aliases'): + diff[key] = 'REPLACE' + else: + diff[key] = "UPDATE" elif key not in prot2: + diff[key] = "UPDATE" + for key in prot2: + if key not in diff and key not in prot1: diff[key] = "REMOVE" return diff @@ -246,25 +270,42 @@ def batch_update_objects_with_prototype(prototype, diff=None, objects=None): changed (int): The number of objects that had changes applied to them. """ - prototype_key = prototype if isinstance(prototype, basestring) else prototype['prototype_key'] - prototype_obj = protlib.DbPrototype.objects.filter(db_key=prototype_key) - prototype_obj = prototype_obj[0] if prototype_obj else None - new_prototype = prototype_obj.db.prototype - objs = ObjectDB.objects.get_by_tag(prototype_key, category=_PROTOTYPE_TAG_CATEGORY) + if isinstance(prototype, basestring): + new_prototype = protlib.search_prototype(prototype) + else: + new_prototype = prototype - if not objs: + prototype_key = new_prototype['prototype_key'] + + if not objects: + objects = ObjectDB.objects.get_by_tag(prototype_key, category=_PROTOTYPE_TAG_CATEGORY) + + if not objects: return 0 if not diff: - diff = prototype_diff_from_object(new_prototype, objs[0]) + diff = prototype_diff_from_object(new_prototype, objects[0]) changed = 0 - for obj in objs: + for obj in objects: do_save = False + + old_prot_key = obj.tags.get(category=_PROTOTYPE_TAG_CATEGORY, return_list=True) + old_prot_key = old_prot_key[0] if old_prot_key else None + if prototype_key != old_prot_key: + obj.tags.clear(category=_PROTOTYPE_TAG_CATEGORY) + obj.tags.add(prototype_key, category=_PROTOTYPE_TAG_CATEGORY) + for key, directive in diff.items(): - val = new_prototype[key] if directive in ('UPDATE', 'REPLACE'): + + if key in _PROTOTYPE_META_NAMES: + # prototype meta keys are not stored on-object + continue + + val = new_prototype[key] do_save = True + if key == 'key': obj.db_key = init_spawn_value(val, str) elif key == 'typeclass': @@ -282,19 +323,19 @@ def batch_update_objects_with_prototype(prototype, diff=None, objects=None): elif key == 'permissions': if directive == 'REPLACE': obj.permissions.clear() - obj.permissions.batch_add(init_spawn_value(val, make_iter)) + obj.permissions.batch_add(*init_spawn_value(val, make_iter)) elif key == 'aliases': if directive == 'REPLACE': obj.aliases.clear() - obj.aliases.batch_add(init_spawn_value(val, make_iter)) + obj.aliases.batch_add(*init_spawn_value(val, make_iter)) elif key == 'tags': if directive == 'REPLACE': obj.tags.clear() - obj.tags.batch_add(init_spawn_value(val, make_iter)) + obj.tags.batch_add(*init_spawn_value(val, make_iter)) elif key == 'attrs': if directive == 'REPLACE': obj.attributes.clear() - obj.attributes.batch_add(init_spawn_value(val, make_iter)) + obj.attributes.batch_add(*init_spawn_value(val, make_iter)) elif key == 'exec': # we don't auto-rerun exec statements, it would be huge security risk! pass @@ -328,9 +369,9 @@ def batch_update_objects_with_prototype(prototype, diff=None, objects=None): pass else: obj.attributes.remove(key) - if do_save: - changed += 1 - obj.save() + if do_save: + changed += 1 + obj.save() return changed diff --git a/evennia/prototypes/tests.py b/evennia/prototypes/tests.py index e9ef4bce9f..b358043e9d 100644 --- a/evennia/prototypes/tests.py +++ b/evennia/prototypes/tests.py @@ -4,6 +4,8 @@ Unit tests for the prototypes and spawner """ from random import randint +import mock +from anything import Anything, Something from evennia.utils.test_resources import EvenniaTest from evennia.prototypes import spawner, prototypes as protlib @@ -56,6 +58,99 @@ class TestSpawner(EvenniaTest): prototype_parents=_PROTPARENTS)], ['goblin grunt', 'goblin archwizard']) +class TestUtils(EvenniaTest): + + def test_prototype_from_object(self): + self.maxDiff = None + self.obj1.attributes.add("test", "testval") + self.obj1.tags.add('foo') + new_prot = spawner.prototype_from_object(self.obj1) + self.assertEqual( + {'attrs': [('test', 'testval', None, [''])], + 'home': Something, + 'key': 'Obj', + 'location': Something, + 'locks': ['call:true()', + 'control:perm(Developer)', + 'delete:perm(Admin)', + 'edit:perm(Admin)', + 'examine:perm(Builder)', + 'get:all()', + 'puppet:pperm(Developer)', + 'tell:perm(Admin)', + 'view:all()'], + 'prototype_desc': 'Built from Obj', + 'prototype_key': Something, + 'prototype_locks': 'spawn:all();edit:all()', + 'tags': [(u'foo', None, None)], + 'typeclass': 'evennia.objects.objects.DefaultObject'}, new_prot) + + def test_update_objects_from_prototypes(self): + + self.maxDiff = None + self.obj1.attributes.add('oldtest', 'to_remove') + + old_prot = spawner.prototype_from_object(self.obj1) + + # modify object away from prototype + self.obj1.attributes.add('test', 'testval') + self.obj1.aliases.add('foo') + self.obj1.key = 'NewObj' + + # modify prototype + old_prot['new'] = 'new_val' + old_prot['test'] = 'testval_changed' + old_prot['permissions'] = 'Builder' + # this will not update, since we don't update the prototype on-disk + old_prot['prototype_desc'] = 'New version of prototype' + + # diff obj/prototype + pdiff = spawner.prototype_diff_from_object(old_prot, self.obj1) + + self.assertEqual( + pdiff, + {'aliases': 'REMOVE', + 'attrs': 'REPLACE', + 'home': 'KEEP', + 'key': 'UPDATE', + 'location': 'KEEP', + 'locks': 'KEEP', + 'new': 'UPDATE', + 'permissions': 'UPDATE', + 'prototype_desc': 'UPDATE', + 'prototype_key': 'UPDATE', + 'prototype_locks': 'KEEP', + 'test': 'UPDATE', + 'typeclass': 'KEEP'}) + + # apply diff + count = spawner.batch_update_objects_with_prototype( + old_prot, diff=pdiff, objects=[self.obj1]) + self.assertEqual(count, 1) + + new_prot = spawner.prototype_from_object(self.obj1) + self.assertEqual({'attrs': [('test', 'testval_changed', None, ['']), + ('new', 'new_val', None, [''])], + 'home': Something, + 'key': 'Obj', + 'location': Something, + 'locks': ['call:true()', + 'control:perm(Developer)', + 'delete:perm(Admin)', + 'edit:perm(Admin)', + 'examine:perm(Builder)', + 'get:all()', + 'puppet:pperm(Developer)', + 'tell:perm(Admin)', + 'view:all()'], + 'permissions': 'builder', + 'prototype_desc': 'Built from Obj', + 'prototype_key': Something, + 'prototype_locks': 'spawn:all();edit:all()', + 'typeclass': 'evennia.objects.objects.DefaultObject'}, + new_prot) + + class TestPrototypeStorage(EvenniaTest): def setUp(self): diff --git a/requirements.txt b/requirements.txt index 7f4b94726f..72df29b9d0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,9 +2,11 @@ django > 1.10, < 2.0 twisted == 16.0.0 -mock >= 1.0.1 pillow == 2.9.0 pytz future >= 0.15.2 django-sekizai inflect + +mock >= 1.0.1 +anything From 2474ab65801fb918d935bfba9434b239973e0bf2 Mon Sep 17 00:00:00 2001 From: Griatch Date: Sun, 10 Jun 2018 20:00:35 +0200 Subject: [PATCH 137/208] Fix unittests, resolve bugs --- evennia/locks/lockhandler.py | 2 +- evennia/prototypes/prototypes.py | 44 ++++++++------ evennia/prototypes/spawner.py | 1 + evennia/prototypes/tests.py | 98 +++++++++++++++++++------------- 4 files changed, 87 insertions(+), 58 deletions(-) diff --git a/evennia/locks/lockhandler.py b/evennia/locks/lockhandler.py index 4822dde1b6..9e27ca2fad 100644 --- a/evennia/locks/lockhandler.py +++ b/evennia/locks/lockhandler.py @@ -660,7 +660,7 @@ def validate_lockstring(lockstring): if no error was found. """ - return _LOCK_HANDLER.valdate(lockstring) + return _LOCK_HANDLER.validate(lockstring) def _test(): diff --git a/evennia/prototypes/prototypes.py b/evennia/prototypes/prototypes.py index bb917a8dc4..2e96af99c9 100644 --- a/evennia/prototypes/prototypes.py +++ b/evennia/prototypes/prototypes.py @@ -21,7 +21,8 @@ from evennia.prototypes.protfuncs import protfunc_parser _MODULE_PROTOTYPE_MODULES = {} _MODULE_PROTOTYPES = {} _PROTOTYPE_META_NAMES = ("prototype_key", "prototype_desc", "prototype_tags", "prototype_locks") -_PROTOTYPE_TAG_CATEGORY = "spawned_by_prototype" +_PROTOTYPE_TAG_CATEGORY = "from_prototype" +_PROTOTYPE_TAG_META_CATEGORY = "db_prototype" class PermissionError(RuntimeError): @@ -167,7 +168,7 @@ class DbPrototype(DefaultScript): def create_prototype(**kwargs): """ - Store a prototype persistently. + Create/Store a prototype persistently. Kwargs: prototype_key (str): This is required for any storage. @@ -204,36 +205,45 @@ def create_prototype(**kwargs): raise PermissionError("{} is a read-only prototype " "(defined as code in {}).".format(prototype_key, mod)) - # want to create- or edit - prototype = kwargs - # make sure meta properties are included with defaults - prototype['prototype_desc'] = prototype.get('prototype_desc', '') - locks = prototype.get('prototype_locks', "spawn:all();edit:perm(Admin)") - is_valid, err = validate_lockstring(locks) + stored_prototype = DbPrototype.objects.filter(db_key=prototype_key) + prototype = dict(stored_prototype[0].db.prototype) if stored_prototype else {} + + kwargs['prototype_desc'] = kwargs.get("prototype_desc", prototype.get("prototype_desc", "")) + prototype_locks = kwargs.get( + "prototype_locks", prototype.get('prototype_locks', "spawn:all();edit:perm(Admin)")) + is_valid, err = validate_lockstring(prototype_locks) if not is_valid: raise ValidationError("Lock error: {}".format(err)) - prototype["prototype_locks"] = locks - prototype["prototype_tags"] = [ - _to_batchtuple(tag, "db_prototype") - for tag in make_iter(prototype.get("prototype_tags", []))] + kwargs['prototype_locks'] = prototype_locks - stored_prototype = DbPrototype.objects.filter(db_key=prototype_key) + prototype_tags = [ + _to_batchtuple(tag, _PROTOTYPE_TAG_META_CATEGORY) + for tag in make_iter(kwargs.get("prototype_tags", + prototype.get('prototype_tags', [])))] + kwargs["prototype_tags"] = prototype_tags + + prototype.update(kwargs) if stored_prototype: # edit existing prototype stored_prototype = stored_prototype[0] - stored_prototype.desc = prototype['prototype_desc'] - stored_prototype.tags.batch_add(*prototype['prototype_tags']) + if prototype_tags: + stored_prototype.tags.clear(category=_PROTOTYPE_TAG_CATEGORY) + stored_prototype.tags.batch_add(*prototype['prototype_tags']) stored_prototype.locks.add(prototype['prototype_locks']) stored_prototype.attributes.add('prototype', prototype) else: # create a new prototype stored_prototype = create_script( DbPrototype, key=prototype_key, desc=prototype['prototype_desc'], persistent=True, - locks=locks, tags=prototype['prototype_tags'], attributes=[("prototype", prototype)]) - return stored_prototype + locks=prototype_locks, tags=prototype['prototype_tags'], + attributes=[("prototype", prototype)]) + return stored_prototype.db.prototype + +# alias +save_prototype = create_prototype def delete_prototype(key, caller=None): diff --git a/evennia/prototypes/spawner.py b/evennia/prototypes/spawner.py index f34fe8c854..22add7830a 100644 --- a/evennia/prototypes/spawner.py +++ b/evennia/prototypes/spawner.py @@ -186,6 +186,7 @@ def prototype_from_object(obj): obj.key, hashlib.md5(str(time.time())).hexdigest()[:7]) prot['prototype_desc'] = "Built from {}".format(str(obj)) prot['prototype_locks'] = "spawn:all();edit:all()" + prot['prototype_tags'] = [] prot['key'] = obj.db_key or hashlib.md5(str(time.time())).hexdigest()[:6] prot['typeclass'] = obj.db_typeclass_path diff --git a/evennia/prototypes/tests.py b/evennia/prototypes/tests.py index b358043e9d..88650caa7b 100644 --- a/evennia/prototypes/tests.py +++ b/evennia/prototypes/tests.py @@ -9,6 +9,7 @@ from anything import Anything, Something from evennia.utils.test_resources import EvenniaTest from evennia.prototypes import spawner, prototypes as protlib +from evennia.prototypes.prototypes import _PROTOTYPE_TAG_META_CATEGORY _PROTPARENTS = { "NOBODY": {}, @@ -151,63 +152,80 @@ class TestUtils(EvenniaTest): new_prot) +class TestProtLib(EvenniaTest): + + def setUp(self): + super(TestProtLib, self).setUp() + self.obj1.attributes.add("testattr", "testval") + self.prot = spawner.prototype_from_object(self.obj1) + + def test_prototype_to_str(self): + prstr = protlib.prototype_to_str(self.prot) + self.assertTrue(prstr.startswith("|cprototype key:|n")) + + def test_check_permission(self): + pass + class TestPrototypeStorage(EvenniaTest): def setUp(self): super(TestPrototypeStorage, self).setUp() - self.prot1 = {"prototype_key": "testprototype"} - self.prot2 = {"prototype_key": "testprototype2"} - self.prot3 = {"prototype_key": "testprototype3"} + self.maxDiff = None - def _get_metaproto( - self, key='testprototype', desc='testprototype', - locks=['edit:id(6) or perm(Admin)', 'use:all()'], - tags=[], prototype={"key": "testprototype"}): - return spawner.build_metaproto(key, desc, locks, tags, prototype) + self.prot1 = spawner.prototype_from_object(self.obj1) + self.prot1['prototype_key'] = 'testprototype1' + self.prot1['prototype_desc'] = 'testdesc1' + self.prot1['prototype_tags'] = [('foo1', _PROTOTYPE_TAG_META_CATEGORY)] - def _to_metaproto(self, db_prototype): - return spawner.build_metaproto( - db_prototype.key, db_prototype.desc, db_prototype.locks.all(), - db_prototype.tags.get(category="db_prototype", return_list=True), - db_prototype.attributes.get("prototype")) + self.prot2 = self.prot1.copy() + self.prot2['prototype_key'] = 'testprototype2' + self.prot2['prototype_desc'] = 'testdesc2' + self.prot2['prototype_tags'] = [('foo1', _PROTOTYPE_TAG_META_CATEGORY)] + + self.prot3 = self.prot2.copy() + self.prot3['prototype_key'] = 'testprototype3' + self.prot3['prototype_desc'] = 'testdesc3' + self.prot3['prototype_tags'] = [('foo1', _PROTOTYPE_TAG_META_CATEGORY)] def test_prototype_storage(self): - prot = spawner.save_db_prototype(self.char1, self.prot1, "testprot", - desc='testdesc0', tags=["foo"]) + prot1 = protlib.create_prototype(**self.prot1) - self.assertTrue(bool(prot)) - self.assertEqual(prot.db.prototype, self.prot1) - self.assertEqual(prot.desc, "testdesc0") + self.assertTrue(bool(prot1)) + self.assertEqual(prot1, self.prot1) - prot = spawner.save_db_prototype(self.char1, self.prot1, "testprot", - desc='testdesc', tags=["fooB"]) - self.assertEqual(prot.db.prototype, self.prot1) - self.assertEqual(prot.desc, "testdesc") - self.assertTrue(bool(prot.tags.get("fooB", "db_prototype"))) + self.assertEqual(prot1['prototype_desc'], "testdesc1") - self.assertEqual(list(prot.__class__.objects.get_by_tag("foo", "db_prototype")), [prot]) - - prot2 = spawner.save_db_prototype(self.char1, self.prot2, "testprot2", - desc='testdesc2b', tags=["foo"]) + self.assertEqual(prot1['prototype_tags'], [("foo1", _PROTOTYPE_TAG_META_CATEGORY)]) self.assertEqual( - list(prot.__class__.objects.get_by_tag("foo", "db_prototype")), [prot, prot2]) + protlib.DbPrototype.objects.get_by_tag( + "foo1", _PROTOTYPE_TAG_META_CATEGORY)[0].db.prototype, prot1) - prot3 = spawner.save_db_prototype(self.char1, self.prot3, "testprot2", desc='testdesc2') - self.assertEqual(prot2.id, prot3.id) + prot2 = protlib.create_prototype(**self.prot2) self.assertEqual( - list(prot.__class__.objects.get_by_tag("foo", "db_prototype")), [prot, prot2]) + [pobj.db.prototype + for pobj in protlib.DbPrototype.objects.get_by_tag( + "foo1", _PROTOTYPE_TAG_META_CATEGORY)], + [prot1, prot2]) - # returns DBPrototype - self.assertEqual(list(spawner.search_db_prototype("testprot", return_queryset=True)), [prot]) + # add to existing prototype + prot1b = protlib.create_prototype( + prototype_key='testprototype1', foo='bar', prototype_tags=['foo2']) - prot = prot.db.prototype - prot3 = prot3.db.prototype - self.assertEqual(list(spawner.search_prototype("testprot")), [prot]) self.assertEqual( - list(spawner.search_prototype("testprot")), [self.prot1]) + [pobj.db.prototype + for pobj in protlib.DbPrototype.objects.get_by_tag( + "foo2", _PROTOTYPE_TAG_META_CATEGORY)], + [prot1b]) + + self.assertEqual(list(protlib.search_prototype("testprototype2")), [prot2]) + self.assertNotEqual(list(protlib.search_prototype("testprototype1")), [prot1]) + self.assertEqual(list(protlib.search_prototype("testprototype1")), [prot1b]) + + prot3 = protlib.create_prototype(**self.prot3) + # partial match - self.assertEqual(list(spawner.search_prototype("prot")), [prot, prot3]) - self.assertEqual(list(spawner.search_prototype(tags="foo")), [prot, prot3]) + self.assertEqual(list(protlib.search_prototype("prot")), [prot1b, prot2, prot3]) + self.assertEqual(list(protlib.search_prototype(tags="foo1")), [prot1b, prot2, prot3]) - self.assertTrue(str(unicode(spawner.list_prototypes(self.char1)))) + self.assertTrue(str(unicode(protlib.list_prototypes(self.char1)))) From 49622a05342fb6e126a311044dc84a92f2c8062f Mon Sep 17 00:00:00 2001 From: Griatch Date: Sun, 10 Jun 2018 21:02:09 +0200 Subject: [PATCH 138/208] Fix unittests; still missing protfunc tests and menus --- evennia/commands/default/building.py | 18 +++++++++--------- evennia/commands/default/tests.py | 10 +++++----- evennia/contrib/tutorial_world/objects.py | 2 +- evennia/prototypes/spawner.py | 4 ++-- evennia/prototypes/tests.py | 3 +++ 5 files changed, 20 insertions(+), 17 deletions(-) diff --git a/evennia/commands/default/building.py b/evennia/commands/default/building.py index 692dd2aac6..301bd03761 100644 --- a/evennia/commands/default/building.py +++ b/evennia/commands/default/building.py @@ -13,7 +13,7 @@ from evennia.utils import create, utils, search from evennia.utils.utils import inherits_from, class_from_module, get_all_typeclasses from evennia.utils.eveditor import EvEditor from evennia.utils.evmore import EvMore -from evennia.prototypes import spawner +from evennia.prototypes import spawner, prototypes as protlib from evennia.utils.ansi import raw COMMAND_DEFAULT_CLASS = class_from_module(settings.COMMAND_DEFAULT_CLASS) @@ -2887,7 +2887,7 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): "use the 'exec' prototype key.") return None try: - spawner.validate_prototype(prototype) + protlib.validate_prototype(prototype) except RuntimeError as err: self.caller.msg(str(err)) return @@ -2929,7 +2929,7 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): if ';' in self.args: key, tags = (part.strip().lower() for part in self.args.split(";", 1)) tags = [tag.strip() for tag in tags.split(",")] if tags else None - EvMore(caller, unicode(spawner.list_prototypes(caller, key=key, tags=tags)), + EvMore(caller, unicode(protlib.list_prototypes(caller, key=key, tags=tags)), exit_on_lastpage=True) return @@ -2947,7 +2947,7 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): if 'list' in self.switches: # for list, all optional arguments are tags - EvMore(caller, unicode(spawner.list_prototypes(caller, + EvMore(caller, unicode(protlib.list_prototypes(caller, tags=self.lhslist)), exit_on_lastpage=True) return @@ -3049,7 +3049,7 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): return if not self.args: - ncount = len(spawner.search_prototype()) + ncount = len(protlib.search_prototype()) caller.msg("Usage: @spawn or {{key: value, ...}}" "\n ({} existing prototypes. Use /list to inspect)".format(ncount)) return @@ -3065,7 +3065,7 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): caller.msg("|rDeletion cancelled.|n") return try: - success = spawner.delete_db_prototype(caller, self.args) + success = protlib.delete_db_prototype(caller, self.args) except PermissionError as err: caller.msg("|rError deleting:|R {}|n".format(err)) caller.msg("Deletion {}.".format( @@ -3077,7 +3077,7 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): if 'update' in self.switches: # update existing prototypes key = self.args.strip().lower() - existing_objects = spawner.search_objects_with_prototype(key) + existing_objects = protlib.search_objects_with_prototype(key) if existing_objects: n_existing = len(existing_objects) slow = " (note that this may be slow)" if n_existing > 10 else "" @@ -3103,7 +3103,7 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): if isinstance(prototype, basestring): # A prototype key we are looking to apply key = prototype - prototypes = spawner.search_prototype(prototype) + prototypes = protlib.search_prototype(prototype) nprots = len(prototypes) if not prototypes: caller.msg("No prototype named '%s'." % prototype) @@ -3115,7 +3115,7 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): return # we have a prototype, check access prototype = prototypes[0] - if not caller.locks.check_lockstring(caller, prototype.get('prototype_locks', ''), access_type='use'): + if not caller.locks.check_lockstring(caller, prototype.get('prototype_locks', ''), access_type='spawn'): caller.msg("You don't have access to use this prototype.") return diff --git a/evennia/commands/default/tests.py b/evennia/commands/default/tests.py index ffb877c3e3..e1688cdb48 100644 --- a/evennia/commands/default/tests.py +++ b/evennia/commands/default/tests.py @@ -28,7 +28,7 @@ from evennia.utils import ansi, utils, gametime from evennia.server.sessionhandler import SESSIONS from evennia import search_object from evennia import DefaultObject, DefaultCharacter -from evennia.utils import spawner +from evennia.prototypes import spawner, prototypes as protlib # set up signal here since we are not starting the server @@ -389,16 +389,16 @@ class TestBuilding(CommandTest): spawnLoc = self.room1 self.call(building.CmdSpawn(), - "{'prototype':'GOBLIN', 'key':'goblin', 'location':'%s'}" + "{'prototype_key':'GOBLIN', 'key':'goblin', 'location':'%s'}" % spawnLoc.dbref, "Spawned goblin") goblin = getObject(self, "goblin") self.assertEqual(goblin.location, spawnLoc) goblin.delete() - spawner.save_db_prototype(self.char1, {'key': 'Ball', 'prototype': 'GOBLIN'}, 'ball') + protlib.create_prototype(**{'key': 'Ball', 'prototype': 'GOBLIN', 'prototype_key': 'testball'}) # Tests "@spawn " - self.call(building.CmdSpawn(), "ball", "Spawned Ball") + self.call(building.CmdSpawn(), "testball", "Spawned Ball") ball = getObject(self, "Ball") self.assertEqual(ball.location, self.char1.location) self.assertIsInstance(ball, DefaultObject) @@ -414,7 +414,7 @@ class TestBuilding(CommandTest): # Tests "@spawn/noloc ...", but DO specify a location. # Location should be the specified location. self.call(building.CmdSpawn(), - "/noloc {'prototype':'BALL', 'location':'%s'}" + "/noloc {'prototype':'TESTBALL', 'location':'%s'}" % spawnLoc.dbref, "Spawned Ball") ball = getObject(self, "Ball") self.assertEqual(ball.location, spawnLoc) diff --git a/evennia/contrib/tutorial_world/objects.py b/evennia/contrib/tutorial_world/objects.py index b260770577..807b4d5e09 100644 --- a/evennia/contrib/tutorial_world/objects.py +++ b/evennia/contrib/tutorial_world/objects.py @@ -24,7 +24,7 @@ import random from evennia import DefaultObject, DefaultExit, Command, CmdSet from evennia.utils import search, delay -from evennia.utils.spawner import spawn +from evennia.prototypes.spawner import spawn # ------------------------------------------------------------- # diff --git a/evennia/prototypes/spawner.py b/evennia/prototypes/spawner.py index 22add7830a..da4d69eeb4 100644 --- a/evennia/prototypes/spawner.py +++ b/evennia/prototypes/spawner.py @@ -132,13 +132,13 @@ import evennia from evennia.objects.models import ObjectDB from evennia.utils.utils import make_iter, is_iter from evennia.prototypes import prototypes as protlib -from evennia.prototypes.prototypes import value_to_obj, value_to_obj_or_any, init_spawn_value +from evennia.prototypes.prototypes import ( + value_to_obj, value_to_obj_or_any, init_spawn_value, _PROTOTYPE_TAG_CATEGORY) _CREATE_OBJECT_KWARGS = ("key", "location", "home", "destination") _PROTOTYPE_META_NAMES = ("prototype_key", "prototype_desc", "prototype_tags", "prototype_locks") _NON_CREATE_KWARGS = _CREATE_OBJECT_KWARGS + _PROTOTYPE_META_NAMES -_PROTOTYPE_TAG_CATEGORY = "spawned_by_prototype" # Helper diff --git a/evennia/prototypes/tests.py b/evennia/prototypes/tests.py index 88650caa7b..94bce1f946 100644 --- a/evennia/prototypes/tests.py +++ b/evennia/prototypes/tests.py @@ -83,6 +83,7 @@ class TestUtils(EvenniaTest): 'prototype_desc': 'Built from Obj', 'prototype_key': Something, 'prototype_locks': 'spawn:all();edit:all()', + 'prototype_tags': [], 'tags': [(u'foo', None, None)], 'typeclass': 'evennia.objects.objects.DefaultObject'}, new_prot) @@ -121,6 +122,7 @@ class TestUtils(EvenniaTest): 'prototype_desc': 'UPDATE', 'prototype_key': 'UPDATE', 'prototype_locks': 'KEEP', + 'prototype_tags': 'KEEP', 'test': 'UPDATE', 'typeclass': 'KEEP'}) @@ -148,6 +150,7 @@ class TestUtils(EvenniaTest): 'prototype_desc': 'Built from Obj', 'prototype_key': Something, 'prototype_locks': 'spawn:all();edit:all()', + 'prototype_tags': [], 'typeclass': 'evennia.objects.objects.DefaultObject'}, new_prot) From dad9029c43a826d3ea60526cc7447c0cdfa96f1a Mon Sep 17 00:00:00 2001 From: Griatch Date: Tue, 12 Jun 2018 00:08:14 +0200 Subject: [PATCH 139/208] Add a selection of default protfuncs --- evennia/prototypes/protfuncs.py | 181 +++++++++++++++++++++++++++++++- 1 file changed, 178 insertions(+), 3 deletions(-) diff --git a/evennia/prototypes/protfuncs.py b/evennia/prototypes/protfuncs.py index 057f5f770f..01859452b7 100644 --- a/evennia/prototypes/protfuncs.py +++ b/evennia/prototypes/protfuncs.py @@ -25,6 +25,9 @@ where *args are the arguments given in the prototype, and **kwargs are inserted - session (Session): The Session of the entity spawning using this prototype. - prototype_key (str): The currently spawning prototype-key. - prototype (dict): The dict this protfunc is a part of. + - testing (bool): This is set if this function is called as part of the prototype validation; if + set, the protfunc should take care not to perform any persistent actions, such as operate on + objects or add things to the database. Any traceback raised by this function will be handled at the time of spawning and abort the spawn before any object is created/updated. It must otherwise return the value to store for the specified @@ -32,9 +35,14 @@ prototype key (this value must be possible to serialize in an Attribute). """ +from ast import literal_eval +from random import randint as base_randint, random as base_random + from django.conf import settings from evennia.utils import inlinefuncs from evennia.utils.utils import callables_from_module +from evennia.utils.utils import justify as base_justify, is_iter +from evennia.prototypes.prototypes import value_to_obj_or_any _PROTOTYPEFUNCS = {} @@ -57,7 +65,8 @@ def protfunc_parser(value, available_functions=None, **kwargs): `settings.PROTOTYPEFUNC_MODULES`, or specified on the command line. Args: - value (string): The value to test for a parseable protfunc. + value (any): The value to test for a parseable protfunc. Only strings will be parsed for + protfuncs, all other types are returned as-is. available_functions (dict, optional): Mapping of name:protfunction to use for this parsing. Kwargs: @@ -66,13 +75,179 @@ def protfunc_parser(value, available_functions=None, **kwargs): Returns: any (any): A structure to replace the string on the prototype level. If this is a callable or a (callable, (args,)) structure, it will be executed as if one had supplied - it to the prototype directly. + it to the prototype directly. This structure is also passed through literal_eval so one + can get actual Python primitives out of it (not just strings). It will also identify + eventual object #dbrefs in the output from the protfunc. + """ if not isinstance(value, basestring): return value available_functions = _PROTOTYPEFUNCS if available_functions is None else available_functions - return inlinefuncs.parse_inlinefunc(value, _available_funcs=available_functions, **kwargs) + result = inlinefuncs.parse_inlinefunc(value, _available_funcs=available_functions, **kwargs) + result = value_to_obj_or_any(result) + try: + return literal_eval(result) + except ValueError: + return result + # default protfuncs + +def random(*args, **kwargs): + """ + Usage: $random() + Returns a random value in the interval [0, 1) + + """ + return base_random() + + +def randint(*args, **kwargs): + """ + Usage: $randint(start, end) + Returns random integer in interval [start, end] + + """ + if len(args) != 2: + raise TypeError("$randint needs two arguments - start and end.") + start, end = int(args[0]), int(args[1]) + return base_randint(start, end) + + +def left_justify(*args, **kwargs): + """ + Usage: $left_justify() + Returns left-justified. + + """ + if args: + return base_justify(args[0], align='l') + return "" + + +def right_justify(*args, **kwargs): + """ + Usage: $right_justify() + Returns right-justified across screen width. + + """ + if args: + return base_justify(args[0], align='r') + return "" + + +def center_justify(*args, **kwargs): + + """ + Usage: $center_justify() + Returns centered in screen width. + + """ + if args: + return base_justify(args[0], align='c') + return "" + + +def full_justify(*args, **kwargs): + + """ + Usage: $full_justify() + Returns filling up screen width by adding extra space. + + """ + if args: + return base_justify(args[0], align='f') + return "" + + +def protkey(*args, **kwargs): + """ + Usage: $protkey() + Returns the value of another key in this prototoype. Will raise an error if + the key is not found in this prototype. + + """ + if args: + prototype = kwargs['prototype'] + return prototype[args[0]] + + +def add(*args, **kwargs): + """ + Usage: $add(val1, val2) + Returns the result of val1 + val2. Values must be + valid simple Python structures possible to add, + such as numbers, lists etc. + + """ + if len(args) > 1: + val1, val2 = args[0], args[1] + return literal_eval(val1) + literal_eval(val2) + raise ValueError("$add requires two arguments.") + + +def sub(*args, **kwargs): + """ + Usage: $del(val1, val2) + Returns the value of val1 - val2. Values must be + valid simple Python structures possible to + subtract. + + """ + if len(args) > 1: + val1, val2 = args[0], args[1] + return literal_eval(val1) - literal_eval(val2) + raise ValueError("$sub requires two arguments.") + + +def mul(*args, **kwargs): + """ + Usage: $mul(val1, val2) + Returns the value of val1 * val2. The values must be + valid simple Python structures possible to + multiply, like strings and/or numbers. + + """ + if len(args) > 1: + val1, val2 = args[0], args[1] + return literal_eval(val1) * literal_eval(val2) + raise ValueError("$mul requires two arguments.") + + +def div(*args, **kwargs): + """ + Usage: $div(val1, val2) + Returns the value of val1 / val2. Values must be numbers and + the result is always a float. + + """ + if len(args) > 1: + val1, val2 = args[0], args[1] + return literal_eval(val1) / float(literal_eval(val2)) + raise ValueError("$mult requires two arguments.") + + +def eval(*args, **kwargs): + """ + Usage $eval() + Returns evaluation of a simple Python expression. The string may *only* consist of the following + Python literal structures: strings, numbers, tuples, lists, dicts, booleans, + and None. The strings can also contain #dbrefs. Escape embedded protfuncs as $$protfunc(..) + - those will then be evaluated *after* $eval. + + """ + string = args[0] if args else '' + struct = literal_eval(string) + + def _recursive_parse(val): + # an extra round of recursive parsing, to catch any escaped $$profuncs + if is_iter(val): + stype = type(val) + if stype == dict: + return {_recursive_parse(key): _recursive_parse(v) for key, v in val.items()} + return stype((_recursive_parse(v) for v in val)) + return protfunc_parser(val) + + return _recursive_parse(struct) From 4034de21bb8ff12e111fc8642460388bbc24d076 Mon Sep 17 00:00:00 2001 From: Griatch Date: Tue, 12 Jun 2018 20:10:20 +0200 Subject: [PATCH 140/208] Start protfunc tests (unworking) --- evennia/prototypes/protfuncs.py | 24 +++++++++++++----------- evennia/prototypes/tests.py | 24 +++++++++++++++++++----- evennia/settings_default.py | 3 +++ 3 files changed, 35 insertions(+), 16 deletions(-) diff --git a/evennia/prototypes/protfuncs.py b/evennia/prototypes/protfuncs.py index 01859452b7..853634d9f6 100644 --- a/evennia/prototypes/protfuncs.py +++ b/evennia/prototypes/protfuncs.py @@ -15,7 +15,7 @@ In the prototype dict, the protfunc is specified as a string inside the prototyp and multiple functions can be nested (no keyword args are supported). The result will be used as the value for that prototype key for that individual spawn. -Available protfuncs are callables in one of the modules of `settings.PROTOTYPEFUNC_MODULES`. They +Available protfuncs are callables in one of the modules of `settings.PROT_FUNC_MODULES`. They are specified as functions def funcname (*args, **kwargs) @@ -42,17 +42,16 @@ from django.conf import settings from evennia.utils import inlinefuncs from evennia.utils.utils import callables_from_module from evennia.utils.utils import justify as base_justify, is_iter -from evennia.prototypes.prototypes import value_to_obj_or_any +_PROTLIB = None +_PROT_FUNCS = {} -_PROTOTYPEFUNCS = {} - -for mod in settings.PROTOTYPEFUNC_MODULES: +for mod in settings.PROT_FUNC_MODULES: try: callables = callables_from_module(mod) if mod == __name__: - callables.pop("protfunc_parser") - _PROTOTYPEFUNCS.update(callables) + callables.pop("protfunc_parser", None) + _PROT_FUNCS.update(callables) except ImportError: pass @@ -62,7 +61,7 @@ def protfunc_parser(value, available_functions=None, **kwargs): Parse a prototype value string for a protfunc and process it. Available protfuncs are specified as callables in one of the modules of - `settings.PROTOTYPEFUNC_MODULES`, or specified on the command line. + `settings.PROTFUNC_MODULES`, or specified on the command line. Args: value (any): The value to test for a parseable protfunc. Only strings will be parsed for @@ -81,18 +80,21 @@ def protfunc_parser(value, available_functions=None, **kwargs): """ + global _PROTLIB + if not _PROTLIB: + from evennia.prototypes import prototypes as _PROTLIB + if not isinstance(value, basestring): return value - available_functions = _PROTOTYPEFUNCS if available_functions is None else available_functions + available_functions = _PROT_FUNCS if available_functions is None else available_functions result = inlinefuncs.parse_inlinefunc(value, _available_funcs=available_functions, **kwargs) - result = value_to_obj_or_any(result) + result = _PROTLIB.value_to_obj_or_any(result) try: return literal_eval(result) except ValueError: return result - # default protfuncs def random(*args, **kwargs): diff --git a/evennia/prototypes/tests.py b/evennia/prototypes/tests.py index 94bce1f946..fa7eeca246 100644 --- a/evennia/prototypes/tests.py +++ b/evennia/prototypes/tests.py @@ -6,8 +6,9 @@ Unit tests for the prototypes and spawner from random import randint import mock from anything import Anything, Something +from django.test.utils import override_settings from evennia.utils.test_resources import EvenniaTest -from evennia.prototypes import spawner, prototypes as protlib +from evennia.prototypes import spawner, prototypes as protlib, protfuncs from evennia.prototypes.prototypes import _PROTOTYPE_TAG_META_CATEGORY @@ -40,10 +41,6 @@ _PROTPARENTS = { } -class TestPrototypes(EvenniaTest): - pass - - class TestSpawner(EvenniaTest): def setUp(self): @@ -169,6 +166,23 @@ class TestProtLib(EvenniaTest): def test_check_permission(self): pass + +@override_settings(PROT_FUNC_MODULES=['evennia.prototypes.protfuncs']) +class TestProtFuncs(EvenniaTest): + + def setUp(self): + super(TestProtFuncs, self).setUp() + self.prot = {"prototype_key": "test_prototype", + "prototype_desc": "testing prot", + "key": "ExampleObj"} + + @mock.patch("random.random", new=mock.MagicMock(return_value=0.5)) + @mock.patch("random.randint", new=mock.MagicMock(return_value=5)) + def test_protfuncs(self): + self.assertEqual(protfuncs.protfunc_parser("$random()", 0.5)) + self.assertEqual(protfuncs.protfunc_parser("$randint(1, 10)", 5)) + + class TestPrototypeStorage(EvenniaTest): def setUp(self): diff --git a/evennia/settings_default.py b/evennia/settings_default.py index 1d7adb4375..172fee8922 100644 --- a/evennia/settings_default.py +++ b/evennia/settings_default.py @@ -354,6 +354,9 @@ LOCK_FUNC_MODULES = ("evennia.locks.lockfuncs", "server.conf.lockfuncs",) INPUT_FUNC_MODULES = ["evennia.server.inputfuncs", "server.conf.inputfuncs"] # Modules that contain prototypes for use with the spawner mechanism. PROTOTYPE_MODULES = ["world.prototypes"] +# Modules containining Prototype functions able to be embedded in prototype +# definitions from in-game. +PROT_FUNC_MODULES = ["evennia.prototypes.protfuncs"] # Module holding settings/actions for the dummyrunner program (see the # dummyrunner for more information) DUMMYRUNNER_SETTINGS_MODULE = "evennia.server.profiling.dummyrunner_settings" From e1206b3b8fbf31f063566a4fd2af58a872821fbe Mon Sep 17 00:00:00 2001 From: Griatch Date: Tue, 12 Jun 2018 20:20:20 +0200 Subject: [PATCH 141/208] Fix unittests after merge --- evennia/commands/default/tests.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/evennia/commands/default/tests.py b/evennia/commands/default/tests.py index 941e247f0a..625af4b5ac 100644 --- a/evennia/commands/default/tests.py +++ b/evennia/commands/default/tests.py @@ -333,8 +333,8 @@ class TestBuilding(CommandTest): def test_find(self): self.call(building.CmdFind(), "oom2", "One Match") - expect = "One Match(#1#7, loc):\n " +\ - "Char2(#7) evennia.objects.objects.DefaultCharacter (location: Room(#1))" + expect = "One Match(#1-#7, loc):\n " +\ + "Char2(#7) - evennia.objects.objects.DefaultCharacter (location: Room(#1))" self.call(building.CmdFind(), "Char2", expect, cmdstring="locate") self.call(building.CmdFind(), "/ex Char2", # /ex is an ambiguous switch "locate: Ambiguous switch supplied: Did you mean /exit or /exact?|" + expect, @@ -350,7 +350,7 @@ class TestBuilding(CommandTest): def test_teleport(self): self.call(building.CmdTeleport(), "/quiet Room2", "Room2(#2)\n|Teleported to Room2.") self.call(building.CmdTeleport(), "/t", # /t switch is abbreviated form of /tonone - "Cannot teleport a puppeted object (Char, puppeted by TestAccount(account 1)) to a Nonelocation.") + "Cannot teleport a puppeted object (Char, puppeted by TestAccount(account 1)) to a None-location.") self.call(building.CmdTeleport(), "/l Room2", # /l switch is abbreviated form of /loc "Destination has no location.") self.call(building.CmdTeleport(), "/q me to Room2", # /q switch is abbreviated form of /quiet @@ -507,5 +507,5 @@ class TestUnconnectedCommand(CommandTest): expected = "## BEGIN INFO 1.1\nName: %s\nUptime: %s\nConnected: %d\nVersion: Evennia %s\n## END INFO" % ( settings.SERVERNAME, datetime.datetime.fromtimestamp(gametime.SERVER_START_TIME).ctime(), - SESSIONS.account_count(), utils.get_evennia_version().replace("-", "")) + SESSIONS.account_count(), utils.get_evennia_version()) self.call(unloggedin.CmdUnconnectedInfo(), "", expected) From c211a5414fb294d188bc082dbf5707eacef54106 Mon Sep 17 00:00:00 2001 From: Griatch Date: Tue, 12 Jun 2018 23:50:50 +0200 Subject: [PATCH 142/208] Patch out tickerhandler to avoid reactor testing issues --- evennia/contrib/tests.py | 1 + 1 file changed, 1 insertion(+) diff --git a/evennia/contrib/tests.py b/evennia/contrib/tests.py index ba319e3974..e3577997cf 100644 --- a/evennia/contrib/tests.py +++ b/evennia/contrib/tests.py @@ -1325,6 +1325,7 @@ class TestTurnBattleRangeFunc(EvenniaTest): class TestTurnBattleItemsFunc(EvenniaTest): + @patch("evennia.contrib.turnbattle.tb_items.tickerhandler", new=MagicMock()) def setUp(self): super(TestTurnBattleItemsFunc, self).setUp() self.testroom = create_object(DefaultRoom, key="Test Room") From 199325abafe7bb64c837f5874f40851e21140e4b Mon Sep 17 00:00:00 2001 From: Griatch Date: Wed, 13 Jun 2018 00:55:44 +0200 Subject: [PATCH 143/208] Clarify channel log rotate setting is in bytes --- evennia/settings_default.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/evennia/settings_default.py b/evennia/settings_default.py index 7ae45c4be2..4a0d336db8 100644 --- a/evennia/settings_default.py +++ b/evennia/settings_default.py @@ -137,7 +137,7 @@ LOCKWARNING_LOG_FILE = os.path.join(LOG_DIR, 'lockwarnings.log') CYCLE_LOGFILES = True # Number of lines to append to rotating channel logs when they rotate CHANNEL_LOG_NUM_TAIL_LINES = 20 -# Max size of channel log files before they rotate +# Max size (in bytes) of channel log files before they rotate CHANNEL_LOG_ROTATE_SIZE = 1000000 # Local time zone for this installation. All choices can be found here: # http://www.postgresql.org/docs/8.0/interactive/datetime-keywords.html#DATETIME-TIMEZONE-SET-TABLE From 590ffb646591b7951a0b32346bf6df409cfbad29 Mon Sep 17 00:00:00 2001 From: Griatch Date: Fri, 15 Jun 2018 23:45:55 +0200 Subject: [PATCH 144/208] Work on resolving inlinefunc errors #1498 --- evennia/prototypes/protfuncs.py | 9 ++++++++- evennia/utils/inlinefuncs.py | 32 +++++++++++++++++--------------- 2 files changed, 25 insertions(+), 16 deletions(-) diff --git a/evennia/prototypes/protfuncs.py b/evennia/prototypes/protfuncs.py index 853634d9f6..6e9c7e5679 100644 --- a/evennia/prototypes/protfuncs.py +++ b/evennia/prototypes/protfuncs.py @@ -87,7 +87,14 @@ def protfunc_parser(value, available_functions=None, **kwargs): if not isinstance(value, basestring): return value available_functions = _PROT_FUNCS if available_functions is None else available_functions - result = inlinefuncs.parse_inlinefunc(value, _available_funcs=available_functions, **kwargs) + result = inlinefuncs.parse_inlinefunc(value, available_funcs=available_functions, **kwargs) + # at this point we have a string where all procfuncs were parsed + try: + result = literal_eval(result) + except ValueError: + # this is due to the string not being valid for literal_eval - keep it a string + pass + result = _PROTLIB.value_to_obj_or_any(result) try: return literal_eval(result) diff --git a/evennia/utils/inlinefuncs.py b/evennia/utils/inlinefuncs.py index 2646fb3991..575baf281f 100644 --- a/evennia/utils/inlinefuncs.py +++ b/evennia/utils/inlinefuncs.py @@ -157,6 +157,9 @@ def clr(*args, **kwargs): return text +def null(*args, **kwargs): + return args[0] if args else '' + # we specify a default nomatch function to use if no matching func was # found. This will be overloaded by any nomatch function defined in # the imported modules. @@ -177,10 +180,6 @@ for module in utils.make_iter(settings.INLINEFUNC_MODULES): raise -# remove the core function if we include examples in this module itself -#_INLINE_FUNCS.pop("inline_func_parse", None) - - # The stack size is a security measure. Set to <=0 to disable. try: _STACK_MAXSIZE = settings.INLINEFUNC_STACK_MAXSIZE @@ -198,7 +197,7 @@ _RE_TOKEN = re.compile(r""" (?P(?(?\\'|\\"|\\\)|\\$\w+\()| # escaped tokens should re-appear in text - (?P[\w\s.-\/#!%\^&\*;:=\-_`~\|\(}{\[\]]+|\"{1}|\'{1}) # everything else should also be included""", + (?P[\w\s.-\/#@$\>\ 0 and _STACK_MAXSIZE < len(stack): # if stack is larger than limit, throw away parsing return string + gdict["stackfull"](*args, **kwargs) - else: - # cache the stack + elif usecache: + # cache the stack - we do this also if we don't check the cache above _PARSING_CACHE[string] = stack # run the stack recursively @@ -368,8 +370,8 @@ def parse_inlinefunc(string, strip=False, _available_funcs=None, **kwargs): retval = "" if strip else func(*args, **kwargs) return utils.to_str(retval, force_string=True) - # execute the stack from the cache - return "".join(_run_stack(item) for item in _PARSING_CACHE[string]) + # execute the stack + return "".join(_run_stack(item) for item in stack) # # Nick templating From a9e0ee35400d7ca43afee4d045ae1f2a268f30eb Mon Sep 17 00:00:00 2001 From: Griatch Date: Sat, 16 Jun 2018 22:14:28 +0200 Subject: [PATCH 145/208] Handle missing characters in inlinefunc as per #1498 --- evennia/utils/inlinefuncs.py | 942 +++++++++++++++++------------------ 1 file changed, 471 insertions(+), 471 deletions(-) diff --git a/evennia/utils/inlinefuncs.py b/evennia/utils/inlinefuncs.py index 575baf281f..de03e13c2d 100644 --- a/evennia/utils/inlinefuncs.py +++ b/evennia/utils/inlinefuncs.py @@ -1,471 +1,471 @@ -""" -Inline functions (nested form). - -This parser accepts nested inlinefunctions on the form - -``` -$funcname(arg, arg, ...) -``` - -embedded in any text where any arg can be another $funcname{} call. -This functionality is turned off by default - to activate, -`settings.INLINEFUNC_ENABLED` must be set to `True`. - -Each token starts with "$funcname(" where there must be no space -between the $funcname and (. It ends with a matched ending parentesis. -")". - -Inside the inlinefunc definition, one can use `\` to escape. This is -mainly needed for escaping commas in flowing text (which would -otherwise be interpreted as an argument separator), or to escape `}` -when not intended to close the function block. Enclosing text in -matched `\"\"\"` (triple quotes) or `'''` (triple single-quotes) will -also escape *everything* within without needing to escape individual -characters. - -The available inlinefuncs are defined as global-level functions in -modules defined by `settings.INLINEFUNC_MODULES`. They are identified -by their function name (and ignored if this name starts with `_`). They -should be on the following form: - -```python -def funcname (*args, **kwargs): - # ... -``` - -Here, the arguments given to `$funcname(arg1,arg2)` will appear as the -`*args` tuple. This will be populated by the arguments given to the -inlinefunc in-game - the only part that will be available from -in-game. `**kwargs` are not supported from in-game but are only used -internally by Evennia to make details about the caller available to -the function. The kwarg passed to all functions is `session`, the -Sessionobject for the object seeing the string. This may be `None` if -the string is sent to a non-puppetable object. The inlinefunc should -never raise an exception. - -There are two reserved function names: -- "nomatch": This is called if the user uses a functionname that is - not registered. The nomatch function will get the name of the - not-found function as its first argument followed by the normal - arguments to the given function. If not defined the default effect is - to print `` to replace the unknown function. -- "stackfull": This is called when the maximum nested function stack is reached. - When this happens, the original parsed string is returned and the result of - the `stackfull` inlinefunc is appended to the end. By default this is an - error message. - -Error handling: - Syntax errors, notably not completely closing all inlinefunc - blocks, will lead to the entire string remaining unparsed. - -""" - -import re -from django.conf import settings -from evennia.utils import utils - - -# example/testing inline functions - -def pad(*args, **kwargs): - """ - Inlinefunc. Pads text to given width. - - Args: - text (str, optional): Text to pad. - width (str, optional): Will be converted to integer. Width - of padding. - align (str, optional): Alignment of padding; one of 'c', 'l' or 'r'. - fillchar (str, optional): Character used for padding. Defaults to a space. - - Kwargs: - session (Session): Session performing the pad. - - Example: - `$pad(text, width, align, fillchar)` - - """ - text, width, align, fillchar = "", 78, 'c', ' ' - nargs = len(args) - if nargs > 0: - text = args[0] - if nargs > 1: - width = int(args[1]) if args[1].strip().isdigit() else 78 - if nargs > 2: - align = args[2] if args[2] in ('c', 'l', 'r') else 'c' - if nargs > 3: - fillchar = args[3] - return utils.pad(text, width=width, align=align, fillchar=fillchar) - - -def crop(*args, **kwargs): - """ - Inlinefunc. Crops ingoing text to given widths. - - Args: - text (str, optional): Text to crop. - width (str, optional): Will be converted to an integer. Width of - crop in characters. - suffix (str, optional): End string to mark the fact that a part - of the string was cropped. Defaults to `[...]`. - Kwargs: - session (Session): Session performing the crop. - - Example: - `$crop(text, width=78, suffix='[...]')` - - """ - text, width, suffix = "", 78, "[...]" - nargs = len(args) - if nargs > 0: - text = args[0] - if nargs > 1: - width = int(args[1]) if args[1].strip().isdigit() else 78 - if nargs > 2: - suffix = args[2] - return utils.crop(text, width=width, suffix=suffix) - - -def clr(*args, **kwargs): - """ - Inlinefunc. Colorizes nested text. - - Args: - startclr (str, optional): An ANSI color abbreviation without the - prefix `|`, such as `r` (red foreground) or `[r` (red background). - text (str, optional): Text - endclr (str, optional): The color to use at the end of the string. Defaults - to `|n` (reset-color). - Kwargs: - session (Session): Session object triggering inlinefunc. - - Example: - `$clr(startclr, text, endclr)` - - """ - text = "" - nargs = len(args) - if nargs > 0: - color = args[0].strip() - if nargs > 1: - text = args[1] - text = "|" + color + text - if nargs > 2: - text += "|" + args[2].strip() - else: - text += "|n" - return text - - -def null(*args, **kwargs): - return args[0] if args else '' - -# we specify a default nomatch function to use if no matching func was -# found. This will be overloaded by any nomatch function defined in -# the imported modules. -_INLINE_FUNCS = {"nomatch": lambda *args, **kwargs: "", - "stackfull": lambda *args, **kwargs: "\n (not parsed: inlinefunc stack size exceeded.)"} - - -# load custom inline func modules. -for module in utils.make_iter(settings.INLINEFUNC_MODULES): - try: - _INLINE_FUNCS.update(utils.callables_from_module(module)) - except ImportError as err: - if module == "server.conf.inlinefuncs": - # a temporary warning since the default module changed name - raise ImportError("Error: %s\nPossible reason: mygame/server/conf/inlinefunc.py should " - "be renamed to mygame/server/conf/inlinefuncs.py (note the S at the end)." % err) - else: - raise - - -# The stack size is a security measure. Set to <=0 to disable. -try: - _STACK_MAXSIZE = settings.INLINEFUNC_STACK_MAXSIZE -except AttributeError: - _STACK_MAXSIZE = 20 - -# regex definitions - -_RE_STARTTOKEN = re.compile(r"(?.*?)(?.*?)(?(?(?(?\\'|\\"|\\\)|\\$\w+\()| # escaped tokens should re-appear in text - (?P[\w\s.-\/#@$\>\ 0: - # commas outside strings and inside a callable are - # used to mark argument separation - we use None - # in the stack to indicate such a separation. - stack.append(None) - else: - # no callable active - just a string - stack.append(",") - else: - # the rest - stack.append(gdict["rest"]) - - if ncallable > 0: - # this means not all inlinefuncs were complete - return string - - if _STACK_MAXSIZE > 0 and _STACK_MAXSIZE < len(stack): - # if stack is larger than limit, throw away parsing - return string + gdict["stackfull"](*args, **kwargs) - elif usecache: - # cache the stack - we do this also if we don't check the cache above - _PARSING_CACHE[string] = stack - - # run the stack recursively - def _run_stack(item, depth=0): - retval = item - if isinstance(item, tuple): - if strip: - return "" - else: - func, arglist = item - args = [""] - for arg in arglist: - if arg is None: - # an argument-separating comma - start a new arg - args.append("") - else: - # all other args should merge into one string - args[-1] += _run_stack(arg, depth=depth + 1) - # execute the inlinefunc at this point or strip it. - kwargs["inlinefunc_stack_depth"] = depth - retval = "" if strip else func(*args, **kwargs) - return utils.to_str(retval, force_string=True) - - # execute the stack - return "".join(_run_stack(item) for item in stack) - -# -# Nick templating -# - - -""" -This supports the use of replacement templates in nicks: - -This happens in two steps: - -1) The user supplies a template that is converted to a regex according - to the unix-like templating language. -2) This regex is tested against nicks depending on which nick replacement - strategy is considered (most commonly inputline). -3) If there is a template match and there are templating markers, - these are replaced with the arguments actually given. - -@desc $1 $2 $3 - -This will be converted to the following regex: - -\@desc (?P<1>\w+) (?P<2>\w+) $(?P<3>\w+) - -Supported template markers (through fnmatch) - * matches anything (non-greedy) -> .*? - ? matches any single character -> - [seq] matches any entry in sequence - [!seq] matches entries not in sequence -Custom arg markers - $N argument position (1-99) - -""" -import fnmatch -_RE_NICK_ARG = re.compile(r"\\(\$)([1-9][0-9]?)") -_RE_NICK_TEMPLATE_ARG = re.compile(r"(\$)([1-9][0-9]?)") -_RE_NICK_SPACE = re.compile(r"\\ ") - - -class NickTemplateInvalid(ValueError): - pass - - -def initialize_nick_templates(in_template, out_template): - """ - Initialize the nick templates for matching and remapping a string. - - Args: - in_template (str): The template to be used for nick recognition. - out_template (str): The template to be used to replace the string - matched by the in_template. - - Returns: - regex (regex): Regex to match against strings - template (str): Template with markers {arg1}, {arg2}, etc for - replacement using the standard .format method. - - Raises: - NickTemplateInvalid: If the in/out template does not have a matching - number of $args. - - """ - # create the regex for in_template - regex_string = fnmatch.translate(in_template) - n_inargs = len(_RE_NICK_ARG.findall(regex_string)) - regex_string = _RE_NICK_SPACE.sub("\s+", regex_string) - regex_string = _RE_NICK_ARG.sub(lambda m: "(?P.+?)" % m.group(2), regex_string) - - # create the out_template - template_string = _RE_NICK_TEMPLATE_ARG.sub(lambda m: "{arg%s}" % m.group(2), out_template) - - # validate the tempaltes - they should at least have the same number of args - n_outargs = len(_RE_NICK_TEMPLATE_ARG.findall(out_template)) - if n_inargs != n_outargs: - print n_inargs, n_outargs - raise NickTemplateInvalid - - return re.compile(regex_string), template_string - - -def parse_nick_template(string, template_regex, outtemplate): - """ - Parse a text using a template and map it to another template - - Args: - string (str): The input string to processj - template_regex (regex): A template regex created with - initialize_nick_template. - outtemplate (str): The template to which to map the matches - produced by the template_regex. This should have $1, $2, - etc to match the regex. - - """ - match = template_regex.match(string) - if match: - return outtemplate.format(**match.groupdict()) - return string +""" +Inline functions (nested form). + +This parser accepts nested inlinefunctions on the form + +``` +$funcname(arg, arg, ...) +``` + +embedded in any text where any arg can be another $funcname{} call. +This functionality is turned off by default - to activate, +`settings.INLINEFUNC_ENABLED` must be set to `True`. + +Each token starts with "$funcname(" where there must be no space +between the $funcname and (. It ends with a matched ending parentesis. +")". + +Inside the inlinefunc definition, one can use `\` to escape. This is +mainly needed for escaping commas in flowing text (which would +otherwise be interpreted as an argument separator), or to escape `}` +when not intended to close the function block. Enclosing text in +matched `\"\"\"` (triple quotes) or `'''` (triple single-quotes) will +also escape *everything* within without needing to escape individual +characters. + +The available inlinefuncs are defined as global-level functions in +modules defined by `settings.INLINEFUNC_MODULES`. They are identified +by their function name (and ignored if this name starts with `_`). They +should be on the following form: + +```python +def funcname (*args, **kwargs): + # ... +``` + +Here, the arguments given to `$funcname(arg1,arg2)` will appear as the +`*args` tuple. This will be populated by the arguments given to the +inlinefunc in-game - the only part that will be available from +in-game. `**kwargs` are not supported from in-game but are only used +internally by Evennia to make details about the caller available to +the function. The kwarg passed to all functions is `session`, the +Sessionobject for the object seeing the string. This may be `None` if +the string is sent to a non-puppetable object. The inlinefunc should +never raise an exception. + +There are two reserved function names: +- "nomatch": This is called if the user uses a functionname that is + not registered. The nomatch function will get the name of the + not-found function as its first argument followed by the normal + arguments to the given function. If not defined the default effect is + to print `` to replace the unknown function. +- "stackfull": This is called when the maximum nested function stack is reached. + When this happens, the original parsed string is returned and the result of + the `stackfull` inlinefunc is appended to the end. By default this is an + error message. + +Error handling: + Syntax errors, notably not completely closing all inlinefunc + blocks, will lead to the entire string remaining unparsed. + +""" + +import re +from django.conf import settings +from evennia.utils import utils + + +# example/testing inline functions + +def pad(*args, **kwargs): + """ + Inlinefunc. Pads text to given width. + + Args: + text (str, optional): Text to pad. + width (str, optional): Will be converted to integer. Width + of padding. + align (str, optional): Alignment of padding; one of 'c', 'l' or 'r'. + fillchar (str, optional): Character used for padding. Defaults to a space. + + Kwargs: + session (Session): Session performing the pad. + + Example: + `$pad(text, width, align, fillchar)` + + """ + text, width, align, fillchar = "", 78, 'c', ' ' + nargs = len(args) + if nargs > 0: + text = args[0] + if nargs > 1: + width = int(args[1]) if args[1].strip().isdigit() else 78 + if nargs > 2: + align = args[2] if args[2] in ('c', 'l', 'r') else 'c' + if nargs > 3: + fillchar = args[3] + return utils.pad(text, width=width, align=align, fillchar=fillchar) + + +def crop(*args, **kwargs): + """ + Inlinefunc. Crops ingoing text to given widths. + + Args: + text (str, optional): Text to crop. + width (str, optional): Will be converted to an integer. Width of + crop in characters. + suffix (str, optional): End string to mark the fact that a part + of the string was cropped. Defaults to `[...]`. + Kwargs: + session (Session): Session performing the crop. + + Example: + `$crop(text, width=78, suffix='[...]')` + + """ + text, width, suffix = "", 78, "[...]" + nargs = len(args) + if nargs > 0: + text = args[0] + if nargs > 1: + width = int(args[1]) if args[1].strip().isdigit() else 78 + if nargs > 2: + suffix = args[2] + return utils.crop(text, width=width, suffix=suffix) + + +def clr(*args, **kwargs): + """ + Inlinefunc. Colorizes nested text. + + Args: + startclr (str, optional): An ANSI color abbreviation without the + prefix `|`, such as `r` (red foreground) or `[r` (red background). + text (str, optional): Text + endclr (str, optional): The color to use at the end of the string. Defaults + to `|n` (reset-color). + Kwargs: + session (Session): Session object triggering inlinefunc. + + Example: + `$clr(startclr, text, endclr)` + + """ + text = "" + nargs = len(args) + if nargs > 0: + color = args[0].strip() + if nargs > 1: + text = args[1] + text = "|" + color + text + if nargs > 2: + text += "|" + args[2].strip() + else: + text += "|n" + return text + + +def null(*args, **kwargs): + return args[0] if args else '' + +# we specify a default nomatch function to use if no matching func was +# found. This will be overloaded by any nomatch function defined in +# the imported modules. +_INLINE_FUNCS = {"nomatch": lambda *args, **kwargs: "", + "stackfull": lambda *args, **kwargs: "\n (not parsed: inlinefunc stack size exceeded.)"} + + +# load custom inline func modules. +for module in utils.make_iter(settings.INLINEFUNC_MODULES): + try: + _INLINE_FUNCS.update(utils.callables_from_module(module)) + except ImportError as err: + if module == "server.conf.inlinefuncs": + # a temporary warning since the default module changed name + raise ImportError("Error: %s\nPossible reason: mygame/server/conf/inlinefunc.py should " + "be renamed to mygame/server/conf/inlinefuncs.py (note the S at the end)." % err) + else: + raise + + +# The stack size is a security measure. Set to <=0 to disable. +try: + _STACK_MAXSIZE = settings.INLINEFUNC_STACK_MAXSIZE +except AttributeError: + _STACK_MAXSIZE = 20 + +# regex definitions + +_RE_STARTTOKEN = re.compile(r"(?.*?)(?.*?)(?(?(?(?\\'|\\"|\\\)|\\$\w+\()| # escaped tokens should re-appear in text + (?P[\w\s.-\/#!%\^&\*;:=\-_`~\|\(}{\[\]@\$\\\+\<\>?]+|\"{1}|\'{1}) # everything else """, + re.UNICODE + re.IGNORECASE + re.VERBOSE + re.DOTALL) + +# Cache for function lookups. +_PARSING_CACHE = utils.LimitedSizeOrderedDict(size_limit=1000) + + +class ParseStack(list): + """ + Custom stack that always concatenates strings together when the + strings are added next to one another. Tuples are stored + separately and None is used to mark that a string should be broken + up into a new chunk. Below is the resulting stack after separately + appending 3 strings, None, 2 strings, a tuple and finally 2 + strings: + + [string + string + string, + None + string + string, + tuple, + string + string] + + """ + + def __init__(self, *args, **kwargs): + super(ParseStack, self).__init__(*args, **kwargs) + # always start stack with the empty string + list.append(self, "") + # indicates if the top of the stack is a string or not + self._string_last = True + + def __eq__(self, other): + return (super(ParseStack).__eq__(other) and + hasattr(other, "_string_last") and self._string_last == other._string_last) + + def __ne__(self, other): + return not self.__eq__(other) + + def append(self, item): + """ + The stack will merge strings, add other things as normal + """ + if isinstance(item, basestring): + if self._string_last: + self[-1] += item + else: + list.append(self, item) + self._string_last = True + else: + # everything else is added as normal + list.append(self, item) + self._string_last = False + + +class InlinefuncError(RuntimeError): + pass + + +def parse_inlinefunc(string, strip=False, available_funcs=None, **kwargs): + """ + Parse the incoming string. + + Args: + string (str): The incoming string to parse. + strip (bool, optional): Whether to strip function calls rather than + execute them. + available_funcs (dict, optional): Define an alternative source of functions to parse for. + If unset, use the functions found through `settings.INLINEFUNC_MODULES`. + Kwargs: + session (Session): This is sent to this function by Evennia when triggering + it. It is passed to the inlinefunc. + kwargs (any): All other kwargs are also passed on to the inlinefunc. + + + """ + global _PARSING_CACHE + + usecache = False + if not available_funcs: + available_funcs = _INLINE_FUNCS + usecache = True + + if usecache and string in _PARSING_CACHE: + # stack is already cached + stack = _PARSING_CACHE[string] + elif not _RE_STARTTOKEN.search(string): + # if there are no unescaped start tokens at all, return immediately. + return string + else: + # no cached stack; build a new stack and continue + stack = ParseStack() + + # process string on stack + ncallable = 0 + for match in _RE_TOKEN.finditer(string): + gdict = match.groupdict() + if gdict["singlequote"]: + stack.append(gdict["singlequote"]) + elif gdict["doublequote"]: + stack.append(gdict["doublequote"]) + elif gdict["end"]: + if ncallable <= 0: + stack.append(")") + continue + args = [] + while stack: + operation = stack.pop() + if callable(operation): + if not strip: + stack.append((operation, [arg for arg in reversed(args)])) + ncallable -= 1 + break + else: + args.append(operation) + elif gdict["start"]: + funcname = _RE_STARTTOKEN.match(gdict["start"]).group(1) + try: + # try to fetch the matching inlinefunc from storage + stack.append(available_funcs[funcname]) + except KeyError: + stack.append(available_funcs["nomatch"]) + stack.append(funcname) + ncallable += 1 + elif gdict["escaped"]: + # escaped tokens + token = gdict["escaped"].lstrip("\\") + stack.append(token) + elif gdict["comma"]: + if ncallable > 0: + # commas outside strings and inside a callable are + # used to mark argument separation - we use None + # in the stack to indicate such a separation. + stack.append(None) + else: + # no callable active - just a string + stack.append(",") + else: + # the rest + stack.append(gdict["rest"]) + + if ncallable > 0: + # this means not all inlinefuncs were complete + return string + + if _STACK_MAXSIZE > 0 and _STACK_MAXSIZE < len(stack): + # if stack is larger than limit, throw away parsing + return string + gdict["stackfull"](*args, **kwargs) + elif usecache: + # cache the stack - we do this also if we don't check the cache above + _PARSING_CACHE[string] = stack + + # run the stack recursively + def _run_stack(item, depth=0): + retval = item + if isinstance(item, tuple): + if strip: + return "" + else: + func, arglist = item + args = [""] + for arg in arglist: + if arg is None: + # an argument-separating comma - start a new arg + args.append("") + else: + # all other args should merge into one string + args[-1] += _run_stack(arg, depth=depth + 1) + # execute the inlinefunc at this point or strip it. + kwargs["inlinefunc_stack_depth"] = depth + retval = "" if strip else func(*args, **kwargs) + return utils.to_str(retval, force_string=True) + + print("STACK:\n{}".format(stack)) + # execute the stack + return "".join(_run_stack(item) for item in stack) + +# +# Nick templating +# + + +""" +This supports the use of replacement templates in nicks: + +This happens in two steps: + +1) The user supplies a template that is converted to a regex according + to the unix-like templating language. +2) This regex is tested against nicks depending on which nick replacement + strategy is considered (most commonly inputline). +3) If there is a template match and there are templating markers, + these are replaced with the arguments actually given. + +@desc $1 $2 $3 + +This will be converted to the following regex: + +\@desc (?P<1>\w+) (?P<2>\w+) $(?P<3>\w+) + +Supported template markers (through fnmatch) + * matches anything (non-greedy) -> .*? + ? matches any single character -> + [seq] matches any entry in sequence + [!seq] matches entries not in sequence +Custom arg markers + $N argument position (1-99) + +""" + +import fnmatch +_RE_NICK_ARG = re.compile(r"\\(\$)([1-9][0-9]?)") +_RE_NICK_TEMPLATE_ARG = re.compile(r"(\$)([1-9][0-9]?)") +_RE_NICK_SPACE = re.compile(r"\\ ") + + +class NickTemplateInvalid(ValueError): + pass + + +def initialize_nick_templates(in_template, out_template): + """ + Initialize the nick templates for matching and remapping a string. + + Args: + in_template (str): The template to be used for nick recognition. + out_template (str): The template to be used to replace the string + matched by the in_template. + + Returns: + regex (regex): Regex to match against strings + template (str): Template with markers {arg1}, {arg2}, etc for + replacement using the standard .format method. + + Raises: + NickTemplateInvalid: If the in/out template does not have a matching + number of $args. + + """ + # create the regex for in_template + regex_string = fnmatch.translate(in_template) + n_inargs = len(_RE_NICK_ARG.findall(regex_string)) + regex_string = _RE_NICK_SPACE.sub("\s+", regex_string) + regex_string = _RE_NICK_ARG.sub(lambda m: "(?P.+?)" % m.group(2), regex_string) + + # create the out_template + template_string = _RE_NICK_TEMPLATE_ARG.sub(lambda m: "{arg%s}" % m.group(2), out_template) + + # validate the tempaltes - they should at least have the same number of args + n_outargs = len(_RE_NICK_TEMPLATE_ARG.findall(out_template)) + if n_inargs != n_outargs: + raise NickTemplateInvalid + + return re.compile(regex_string), template_string + + +def parse_nick_template(string, template_regex, outtemplate): + """ + Parse a text using a template and map it to another template + + Args: + string (str): The input string to processj + template_regex (regex): A template regex created with + initialize_nick_template. + outtemplate (str): The template to which to map the matches + produced by the template_regex. This should have $1, $2, + etc to match the regex. + + """ + match = template_regex.match(string) + if match: + return outtemplate.format(**match.groupdict()) + return string From 266971567b8bc6ab1097030fed4d5bc13775c0e2 Mon Sep 17 00:00:00 2001 From: Griatch Date: Sun, 17 Jun 2018 01:22:24 +0200 Subject: [PATCH 146/208] Much improved inlinefunc regex; resolving #1498 --- evennia/utils/inlinefuncs.py | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/evennia/utils/inlinefuncs.py b/evennia/utils/inlinefuncs.py index de03e13c2d..a0ec65e001 100644 --- a/evennia/utils/inlinefuncs.py +++ b/evennia/utils/inlinefuncs.py @@ -188,17 +188,20 @@ except AttributeError: # regex definitions -_RE_STARTTOKEN = re.compile(r"(?.*?)(?.*?)(?(?(?(?\\'|\\"|\\\)|\\$\w+\()| # escaped tokens should re-appear in text - (?P[\w\s.-\/#!%\^&\*;:=\-_`~\|\(}{\[\]@\$\\\+\<\>?]+|\"{1}|\'{1}) # everything else """, - re.UNICODE + re.IGNORECASE + re.VERBOSE + re.DOTALL) + (?.*?)(?.*?)(?(?(?(? # escaped tokens to re-insert sans backslash + \\\'|\\\"|\\\)|\\\$\w+\()| + (?P # everything else to re-insert verbatim + \$(?!\w+\()|\'{1}|\"{1}|\\{1}|[^),$\'\"\\]+)""", + re.UNICODE | re.IGNORECASE | re.VERBOSE | re.DOTALL) # Cache for function lookups. _PARSING_CACHE = utils.LimitedSizeOrderedDict(size_limit=1000) @@ -293,6 +296,7 @@ def parse_inlinefunc(string, strip=False, available_funcs=None, **kwargs): ncallable = 0 for match in _RE_TOKEN.finditer(string): gdict = match.groupdict() + print("match: {}".format({key: val for key, val in gdict.items() if val})) if gdict["singlequote"]: stack.append(gdict["singlequote"]) elif gdict["doublequote"]: From 1c9cce7c2fdab6fc2bbe5dc06f4be4170f7cdcf9 Mon Sep 17 00:00:00 2001 From: Griatch Date: Sun, 17 Jun 2018 01:26:18 +0200 Subject: [PATCH 147/208] Backport inlinefunc regex update from develop olc branch. Resolves #1498. --- evennia/utils/inlinefuncs.py | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/evennia/utils/inlinefuncs.py b/evennia/utils/inlinefuncs.py index e103e217d7..b09cc432e7 100644 --- a/evennia/utils/inlinefuncs.py +++ b/evennia/utils/inlinefuncs.py @@ -191,15 +191,18 @@ except AttributeError: _RE_STARTTOKEN = re.compile(r"(?.*?)(?.*?)(?(?(?(?\\'|\\"|\\\)|\\$\w+\()| # escaped tokens should re-appear in text - (?P[\w\s.-\/#!%\^&\*;:=\-_`~\|\(}{\[\]]+|\"{1}|\'{1}) # everything else should also be included""", - re.UNICODE + re.IGNORECASE + re.VERBOSE + re.DOTALL) + (?.*?)(?.*?)(?(?(?(? # escaped tokens to re-insert sans backslash + \\\'|\\\"|\\\)|\\\$\w+\()| + (?P # everything else to re-insert verbatim + \$(?!\w+\()|\'{1}|\"{1}|\\{1}|[^),$\'\"\\]+)""", + re.UNICODE | re.IGNORECASE | re.VERBOSE | re.DOTALL) # Cache for function lookups. From 274b02c598c2bc75b6a17817965f4a8d46e02da1 Mon Sep 17 00:00:00 2001 From: Griatch Date: Sun, 17 Jun 2018 08:37:29 +0200 Subject: [PATCH 148/208] Handle lone left-parents within inlinefunc --- evennia/utils/inlinefuncs.py | 31 ++++++++++++++++++++++--------- 1 file changed, 22 insertions(+), 9 deletions(-) diff --git a/evennia/utils/inlinefuncs.py b/evennia/utils/inlinefuncs.py index a0ec65e001..4becfb7b01 100644 --- a/evennia/utils/inlinefuncs.py +++ b/evennia/utils/inlinefuncs.py @@ -61,6 +61,7 @@ Error handling: """ import re +import fnmatch from django.conf import settings from evennia.utils import utils @@ -164,7 +165,8 @@ def null(*args, **kwargs): # found. This will be overloaded by any nomatch function defined in # the imported modules. _INLINE_FUNCS = {"nomatch": lambda *args, **kwargs: "", - "stackfull": lambda *args, **kwargs: "\n (not parsed: inlinefunc stack size exceeded.)"} + "stackfull": lambda *args, **kwargs: "\n (not parsed: " + "inlinefunc stack size exceeded.)"} # load custom inline func modules. @@ -175,7 +177,8 @@ for module in utils.make_iter(settings.INLINEFUNC_MODULES): if module == "server.conf.inlinefuncs": # a temporary warning since the default module changed name raise ImportError("Error: %s\nPossible reason: mygame/server/conf/inlinefunc.py should " - "be renamed to mygame/server/conf/inlinefuncs.py (note the S at the end)." % err) + "be renamed to mygame/server/conf/inlinefuncs.py (note " + "the S at the end)." % err) else: raise @@ -190,17 +193,18 @@ except AttributeError: _RE_STARTTOKEN = re.compile(r"(?.*?)(?.*?)(?(?(?(?(?(? # escaped tokens to re-insert sans backslash - \\\'|\\\"|\\\)|\\\$\w+\()| + \\\'|\\\"|\\\)|\\\$\w+\(|\\\()| (?P # everything else to re-insert verbatim - \$(?!\w+\()|\'{1}|\"{1}|\\{1}|[^),$\'\"\\]+)""", + \$(?!\w+\()|\'|\"|\\|[^),$\'\"\\\(]+)""", re.UNICODE | re.IGNORECASE | re.VERBOSE | re.DOTALL) # Cache for function lookups. @@ -294,14 +298,24 @@ def parse_inlinefunc(string, strip=False, available_funcs=None, **kwargs): # process string on stack ncallable = 0 + nlparens = 0 for match in _RE_TOKEN.finditer(string): gdict = match.groupdict() - print("match: {}".format({key: val for key, val in gdict.items() if val})) + # print("match: {}".format({key: val for key, val in gdict.items() if val})) if gdict["singlequote"]: stack.append(gdict["singlequote"]) elif gdict["doublequote"]: stack.append(gdict["doublequote"]) + elif gdict["leftparens"]: + # we have a left-parens inside a callable + if ncallable: + nlparens += 1 + stack.append("(") elif gdict["end"]: + if nlparens > 0: + nlparens -= 1 + stack.append(")") + continue if ncallable <= 0: stack.append(")") continue @@ -373,7 +387,7 @@ def parse_inlinefunc(string, strip=False, available_funcs=None, **kwargs): retval = "" if strip else func(*args, **kwargs) return utils.to_str(retval, force_string=True) - print("STACK:\n{}".format(stack)) + # print("STACK:\n{}".format(stack)) # execute the stack return "".join(_run_stack(item) for item in stack) @@ -410,7 +424,6 @@ Custom arg markers """ -import fnmatch _RE_NICK_ARG = re.compile(r"\\(\$)([1-9][0-9]?)") _RE_NICK_TEMPLATE_ARG = re.compile(r"(\$)([1-9][0-9]?)") _RE_NICK_SPACE = re.compile(r"\\ ") From 5ce7af39498a21a3b7405e2b8fcb6b9bf38ba31d Mon Sep 17 00:00:00 2001 From: Griatch Date: Sun, 17 Jun 2018 23:42:53 +0200 Subject: [PATCH 149/208] Many more tests, debugging of protfuncs/inlinefuncs --- evennia/prototypes/protfuncs.py | 195 ++++++++++++++++++++----------- evennia/prototypes/prototypes.py | 69 ++++++++++- evennia/prototypes/tests.py | 54 ++++++++- evennia/utils/inlinefuncs.py | 26 +++-- evennia/utils/utils.py | 15 +-- 5 files changed, 267 insertions(+), 92 deletions(-) diff --git a/evennia/prototypes/protfuncs.py b/evennia/prototypes/protfuncs.py index 6e9c7e5679..5ecb4b5e7d 100644 --- a/evennia/prototypes/protfuncs.py +++ b/evennia/prototypes/protfuncs.py @@ -23,8 +23,8 @@ are specified as functions where *args are the arguments given in the prototype, and **kwargs are inserted by Evennia: - session (Session): The Session of the entity spawning using this prototype. - - prototype_key (str): The currently spawning prototype-key. - prototype (dict): The dict this protfunc is a part of. + - current_key (str): The active key this value belongs to in the prototype. - testing (bool): This is set if this function is called as part of the prototype validation; if set, the protfunc should take care not to perform any persistent actions, such as operate on objects or add things to the database. @@ -38,68 +38,10 @@ prototype key (this value must be possible to serialize in an Attribute). from ast import literal_eval from random import randint as base_randint, random as base_random -from django.conf import settings -from evennia.utils import inlinefuncs -from evennia.utils.utils import callables_from_module -from evennia.utils.utils import justify as base_justify, is_iter +from evennia.utils import search +from evennia.utils.utils import justify as base_justify, is_iter, to_str _PROTLIB = None -_PROT_FUNCS = {} - -for mod in settings.PROT_FUNC_MODULES: - try: - callables = callables_from_module(mod) - if mod == __name__: - callables.pop("protfunc_parser", None) - _PROT_FUNCS.update(callables) - except ImportError: - pass - - -def protfunc_parser(value, available_functions=None, **kwargs): - """ - Parse a prototype value string for a protfunc and process it. - - Available protfuncs are specified as callables in one of the modules of - `settings.PROTFUNC_MODULES`, or specified on the command line. - - Args: - value (any): The value to test for a parseable protfunc. Only strings will be parsed for - protfuncs, all other types are returned as-is. - available_functions (dict, optional): Mapping of name:protfunction to use for this parsing. - - Kwargs: - any (any): Passed on to the inlinefunc. - - Returns: - any (any): A structure to replace the string on the prototype level. If this is a - callable or a (callable, (args,)) structure, it will be executed as if one had supplied - it to the prototype directly. This structure is also passed through literal_eval so one - can get actual Python primitives out of it (not just strings). It will also identify - eventual object #dbrefs in the output from the protfunc. - - - """ - global _PROTLIB - if not _PROTLIB: - from evennia.prototypes import prototypes as _PROTLIB - - if not isinstance(value, basestring): - return value - available_functions = _PROT_FUNCS if available_functions is None else available_functions - result = inlinefuncs.parse_inlinefunc(value, available_funcs=available_functions, **kwargs) - # at this point we have a string where all procfuncs were parsed - try: - result = literal_eval(result) - except ValueError: - # this is due to the string not being valid for literal_eval - keep it a string - pass - - result = _PROTLIB.value_to_obj_or_any(result) - try: - return literal_eval(result) - except ValueError: - return result # default protfuncs @@ -180,7 +122,7 @@ def protkey(*args, **kwargs): """ if args: prototype = kwargs['prototype'] - return prototype[args[0]] + return prototype[args[0].strip()] def add(*args, **kwargs): @@ -193,7 +135,16 @@ def add(*args, **kwargs): """ if len(args) > 1: val1, val2 = args[0], args[1] - return literal_eval(val1) + literal_eval(val2) + # try to convert to python structures, otherwise, keep as strings + try: + val1 = literal_eval(val1.strip()) + except Exception: + pass + try: + val2 = literal_eval(val2.strip()) + except Exception: + pass + return val1 + val2 raise ValueError("$add requires two arguments.") @@ -207,11 +158,20 @@ def sub(*args, **kwargs): """ if len(args) > 1: val1, val2 = args[0], args[1] - return literal_eval(val1) - literal_eval(val2) + # try to convert to python structures, otherwise, keep as strings + try: + val1 = literal_eval(val1.strip()) + except Exception: + pass + try: + val2 = literal_eval(val2.strip()) + except Exception: + pass + return val1 - val2 raise ValueError("$sub requires two arguments.") -def mul(*args, **kwargs): +def mult(*args, **kwargs): """ Usage: $mul(val1, val2) Returns the value of val1 * val2. The values must be @@ -221,7 +181,16 @@ def mul(*args, **kwargs): """ if len(args) > 1: val1, val2 = args[0], args[1] - return literal_eval(val1) * literal_eval(val2) + # try to convert to python structures, otherwise, keep as strings + try: + val1 = literal_eval(val1.strip()) + except Exception: + pass + try: + val2 = literal_eval(val2.strip()) + except Exception: + pass + return val1 * val2 raise ValueError("$mul requires two arguments.") @@ -234,10 +203,33 @@ def div(*args, **kwargs): """ if len(args) > 1: val1, val2 = args[0], args[1] - return literal_eval(val1) / float(literal_eval(val2)) + # try to convert to python structures, otherwise, keep as strings + try: + val1 = literal_eval(val1.strip()) + except Exception: + pass + try: + val2 = literal_eval(val2.strip()) + except Exception: + pass + return val1 / float(val2) raise ValueError("$mult requires two arguments.") +def toint(*args, **kwargs): + """ + Usage: $toint() + Returns as an integer. + """ + if args: + val = args[0] + try: + return int(literal_eval(val.strip())) + except ValueError: + return val + raise ValueError("$toint requires one argument.") + + def eval(*args, **kwargs): """ Usage $eval() @@ -247,16 +239,79 @@ def eval(*args, **kwargs): - those will then be evaluated *after* $eval. """ - string = args[0] if args else '' + global _PROTLIB + if not _PROTLIB: + from evennia.prototypes import prototypes as _PROTLIB + + string = ",".join(args) struct = literal_eval(string) + if isinstance(struct, basestring): + # we must shield the string, otherwise it will be merged as a string and future + # literal_evals will pick up e.g. '2' as something that should be converted to a number + struct = '"{}"'.format(struct) + def _recursive_parse(val): - # an extra round of recursive parsing, to catch any escaped $$profuncs + # an extra round of recursive parsing after literal_eval, to catch any + # escaped $$profuncs. This is commonly useful for object references. if is_iter(val): stype = type(val) if stype == dict: return {_recursive_parse(key): _recursive_parse(v) for key, v in val.items()} return stype((_recursive_parse(v) for v in val)) - return protfunc_parser(val) + return _PROTLIB.protfunc_parser(val) return _recursive_parse(struct) + + +def _obj_search(return_list=False, *args, **kwargs): + "Helper function to search for an object" + + query = "".join(args) + session = kwargs.get("session", None) + + if not session: + raise ValueError("$obj called by Evennia without Session. This is not supported.") + account = session.account + if not account: + raise ValueError("$obj requires a logged-in account session.") + targets = search.search_object(query) + + if return_list: + retlist = [] + for target in targets: + if target.access(account, target, 'control'): + retlist.append(target) + return retlist + else: + # single-match + if not targets: + raise ValueError("$obj: Query '{}' gave no matches.".format(query)) + if targets.count() > 1: + raise ValueError("$obj: Query '{query}' gave {nmatches} matches. Limit your " + "query or use $objlist instead.".format( + query=query, nmatches=targets.count())) + target = target[0] + if not target.access(account, target, 'control'): + raise ValueError("$obj: Obj {target}(#{dbref} cannot be added - " + "Account {account} does not have 'control' access.".format( + target=target.key, dbref=target.id, account=account)) + return target + + +def obj(*args, **kwargs): + """ + Usage $obj() + Returns one Object searched globally by key, alias or #dbref. Error if more than one. + + """ + return _obj_search(*args, **kwargs) + + +def objlist(*args, **kwargs): + """ + Usage $objlist() + Returns list with one or more Objects searched globally by key, alias or #dbref. + + """ + return _obj_search(return_list=True, *args, **kwargs) diff --git a/evennia/prototypes/prototypes.py b/evennia/prototypes/prototypes.py index 2e96af99c9..86230354b9 100644 --- a/evennia/prototypes/prototypes.py +++ b/evennia/prototypes/prototypes.py @@ -5,17 +5,17 @@ Handling storage of prototypes, both database-based ones (DBPrototypes) and thos """ +from ast import literal_eval from django.conf import settings - from evennia.scripts.scripts import DefaultScript from evennia.objects.models import ObjectDB from evennia.utils.create import create_script from evennia.utils.utils import ( - all_from_module, make_iter, is_iter, dbid_to_obj) + all_from_module, make_iter, is_iter, dbid_to_obj, callables_from_module) from evennia.locks.lockhandler import validate_lockstring, check_lockstring from evennia.utils import logger +from evennia.utils import inlinefuncs from evennia.utils.evtable import EvTable -from evennia.prototypes.protfuncs import protfunc_parser _MODULE_PROTOTYPE_MODULES = {} @@ -23,6 +23,7 @@ _MODULE_PROTOTYPES = {} _PROTOTYPE_META_NAMES = ("prototype_key", "prototype_desc", "prototype_tags", "prototype_locks") _PROTOTYPE_TAG_CATEGORY = "from_prototype" _PROTOTYPE_TAG_META_CATEGORY = "db_prototype" +_PROT_FUNCS = {} class PermissionError(RuntimeError): @@ -36,6 +37,68 @@ class ValidationError(RuntimeError): pass +# Protfunc parsing + +for mod in settings.PROT_FUNC_MODULES: + try: + callables = callables_from_module(mod) + _PROT_FUNCS.update(callables) + except ImportError: + logger.log_trace() + raise + + +def protfunc_parser(value, available_functions=None, testing=False, **kwargs): + """ + Parse a prototype value string for a protfunc and process it. + + Available protfuncs are specified as callables in one of the modules of + `settings.PROTFUNC_MODULES`, or specified on the command line. + + Args: + value (any): The value to test for a parseable protfunc. Only strings will be parsed for + protfuncs, all other types are returned as-is. + available_functions (dict, optional): Mapping of name:protfunction to use for this parsing. + testing (bool, optional): Passed to protfunc. If in a testing mode, some protfuncs may + behave differently. + + Kwargs: + session (Session): Passed to protfunc. Session of the entity spawning the prototype. + protototype (dict): Passed to protfunc. The dict this protfunc is a part of. + current_key(str): Passed to protfunc. The key in the prototype that will hold this value. + any (any): Passed on to the protfunc. + + Returns: + testresult (tuple): If `testing` is set, returns a tuple (error, result) where error is + either None or a string detailing the error from protfunc_parser or seen when trying to + run `literal_eval` on the parsed string. + any (any): A structure to replace the string on the prototype level. If this is a + callable or a (callable, (args,)) structure, it will be executed as if one had supplied + it to the prototype directly. This structure is also passed through literal_eval so one + can get actual Python primitives out of it (not just strings). It will also identify + eventual object #dbrefs in the output from the protfunc. + + """ + if not isinstance(value, basestring): + return value + available_functions = _PROT_FUNCS if available_functions is None else available_functions + result = inlinefuncs.parse_inlinefunc( + value, available_funcs=available_functions, testing=testing, **kwargs) + # at this point we have a string where all procfuncs were parsed + # print("parse_inlinefuncs(\"{}\", available_funcs={}) => {}".format(value, available_functions, result)) + result = value_to_obj_or_any(result) + err = None + try: + result = literal_eval(result) + except ValueError: + pass + except Exception as err: + err = str(err) + if testing: + return err, result + return result + + # helper functions def value_to_obj(value, force=True): diff --git a/evennia/prototypes/tests.py b/evennia/prototypes/tests.py index fa7eeca246..36be5f4c6b 100644 --- a/evennia/prototypes/tests.py +++ b/evennia/prototypes/tests.py @@ -167,7 +167,7 @@ class TestProtLib(EvenniaTest): pass -@override_settings(PROT_FUNC_MODULES=['evennia.prototypes.protfuncs']) +@override_settings(PROT_FUNC_MODULES=['evennia.prototypes.protfuncs'], CLIENT_DEFAULT_WIDTH=20) class TestProtFuncs(EvenniaTest): def setUp(self): @@ -176,11 +176,55 @@ class TestProtFuncs(EvenniaTest): "prototype_desc": "testing prot", "key": "ExampleObj"} - @mock.patch("random.random", new=mock.MagicMock(return_value=0.5)) - @mock.patch("random.randint", new=mock.MagicMock(return_value=5)) + @mock.patch("evennia.prototypes.protfuncs.base_random", new=mock.MagicMock(return_value=0.5)) + @mock.patch("evennia.prototypes.protfuncs.base_randint", new=mock.MagicMock(return_value=5)) def test_protfuncs(self): - self.assertEqual(protfuncs.protfunc_parser("$random()", 0.5)) - self.assertEqual(protfuncs.protfunc_parser("$randint(1, 10)", 5)) + self.assertEqual(protlib.protfunc_parser("$random()"), 0.5) + self.assertEqual(protlib.protfunc_parser("$randint(1, 10)"), 5) + self.assertEqual(protlib.protfunc_parser("$left_justify( foo )"), "foo ") + self.assertEqual(protlib.protfunc_parser("$right_justify( foo )"), " foo") + self.assertEqual(protlib.protfunc_parser("$center_justify(foo )"), " foo ") + self.assertEqual(protlib.protfunc_parser( + "$full_justify(foo bar moo too)"), 'foo bar moo too') + self.assertEqual( + protlib.protfunc_parser("$right_justify( foo )", testing=True), + ('unexpected indent (, line 1)', ' foo')) + + test_prot = {"key1": "value1", + "key2": 2} + + self.assertEqual(protlib.protfunc_parser( + "$protkey(key1)", testing=True, prototype=test_prot), (None, "value1")) + self.assertEqual(protlib.protfunc_parser( + "$protkey(key2)", testing=True, prototype=test_prot), (None, 2)) + + self.assertEqual(protlib.protfunc_parser("$add(1, 2)"), 3) + self.assertEqual(protlib.protfunc_parser("$add(10, 25)"), 35) + self.assertEqual(protlib.protfunc_parser( + "$add('''[1,2,3]''', '''[4,5,6]''')"), [1, 2, 3, 4, 5, 6]) + self.assertEqual(protlib.protfunc_parser("$add(foo, bar)"), "foo bar") + + self.assertEqual(protlib.protfunc_parser("$sub(5, 2)"), 3) + self.assertRaises(TypeError, protlib.protfunc_parser, "$sub(5, test)") + + self.assertEqual(protlib.protfunc_parser("$mult(5, 2)"), 10) + self.assertEqual(protlib.protfunc_parser("$mult( 5 , 10)"), 50) + self.assertEqual(protlib.protfunc_parser("$mult('foo',3)"), "foofoofoo") + self.assertEqual(protlib.protfunc_parser("$mult(foo,3)"), "foofoofoo") + self.assertRaises(TypeError, protlib.protfunc_parser, "$mult(foo, foo)") + + self.assertEqual(protlib.protfunc_parser("$toint(5.3)"), 5) + + self.assertEqual(protlib.protfunc_parser("$div(5, 2)"), 2.5) + self.assertEqual(protlib.protfunc_parser("$toint($div(5, 2))"), 2) + self.assertEqual(protlib.protfunc_parser("$sub($add(5, 3), $add(10, 2))"), -4) + + self.assertEqual(protlib.protfunc_parser("$eval('2')"), '2') + + self.assertEqual(protlib.protfunc_parser( + "$eval(['test', 1, '2', 3.5, \"foo\"])"), ['test', 1, '2', 3.5, 'foo']) + self.assertEqual(protlib.protfunc_parser( + "$eval({'test': '1', 2:3, 3: $toint(3.5)})"), {'test': '1', 2: 3, 3: 3}) class TestPrototypeStorage(EvenniaTest): diff --git a/evennia/utils/inlinefuncs.py b/evennia/utils/inlinefuncs.py index 4becfb7b01..f60f9f0d8a 100644 --- a/evennia/utils/inlinefuncs.py +++ b/evennia/utils/inlinefuncs.py @@ -161,13 +161,15 @@ def clr(*args, **kwargs): def null(*args, **kwargs): return args[0] if args else '' +_INLINE_FUNCS = {} + # we specify a default nomatch function to use if no matching func was # found. This will be overloaded by any nomatch function defined in # the imported modules. -_INLINE_FUNCS = {"nomatch": lambda *args, **kwargs: "", - "stackfull": lambda *args, **kwargs: "\n (not parsed: " - "inlinefunc stack size exceeded.)"} +_DEFAULT_FUNCS = {"nomatch": lambda *args, **kwargs: "", + "stackfull": lambda *args, **kwargs: "\n (not parsed: "} +_INLINE_FUNCS.update(_DEFAULT_FUNCS) # load custom inline func modules. for module in utils.make_iter(settings.INLINEFUNC_MODULES): @@ -285,6 +287,11 @@ def parse_inlinefunc(string, strip=False, available_funcs=None, **kwargs): if not available_funcs: available_funcs = _INLINE_FUNCS usecache = True + else: + # make sure the default keys are available, but also allow overriding + tmp = _DEFAULT_FUNCS.copy() + tmp.update(available_funcs) + available_funcs = tmp if usecache and string in _PARSING_CACHE: # stack is already cached @@ -299,9 +306,14 @@ def parse_inlinefunc(string, strip=False, available_funcs=None, **kwargs): # process string on stack ncallable = 0 nlparens = 0 + + # print("STRING: {} =>".format(string)) + for match in _RE_TOKEN.finditer(string): gdict = match.groupdict() - # print("match: {}".format({key: val for key, val in gdict.items() if val})) + + # print(" MATCH: {}".format({key: val for key, val in gdict.items() if val})) + if gdict["singlequote"]: stack.append(gdict["singlequote"]) elif gdict["doublequote"]: @@ -386,10 +398,10 @@ def parse_inlinefunc(string, strip=False, available_funcs=None, **kwargs): kwargs["inlinefunc_stack_depth"] = depth retval = "" if strip else func(*args, **kwargs) return utils.to_str(retval, force_string=True) - - # print("STACK:\n{}".format(stack)) + retval = "".join(_run_stack(item) for item in stack) + # print("STACK: \n{} => {}\n".format(stack, retval)) # execute the stack - return "".join(_run_stack(item) for item in stack) + return retval # # Nick templating diff --git a/evennia/utils/utils.py b/evennia/utils/utils.py index 22d59a165f..3d07a82e9a 100644 --- a/evennia/utils/utils.py +++ b/evennia/utils/utils.py @@ -43,8 +43,6 @@ _GA = object.__getattribute__ _SA = object.__setattr__ _DA = object.__delattr__ -_DEFAULT_WIDTH = settings.CLIENT_DEFAULT_WIDTH - def is_iter(iterable): """ @@ -80,7 +78,7 @@ def make_iter(obj): return not hasattr(obj, '__iter__') and [obj] or obj -def wrap(text, width=_DEFAULT_WIDTH, indent=0): +def wrap(text, width=None, indent=0): """ Safely wrap text to a certain number of characters. @@ -93,6 +91,7 @@ def wrap(text, width=_DEFAULT_WIDTH, indent=0): text (str): Properly wrapped text. """ + width = width if width else settings.CLIENT_DEFAULT_WIDTH if not text: return "" text = to_unicode(text) @@ -104,7 +103,7 @@ def wrap(text, width=_DEFAULT_WIDTH, indent=0): fill = wrap -def pad(text, width=_DEFAULT_WIDTH, align="c", fillchar=" "): +def pad(text, width=None, align="c", fillchar=" "): """ Pads to a given width. @@ -119,6 +118,7 @@ def pad(text, width=_DEFAULT_WIDTH, align="c", fillchar=" "): text (str): The padded text. """ + width = width if width else settings.CLIENT_DEFAULT_WIDTH align = align if align in ('c', 'l', 'r') else 'c' fillchar = fillchar[0] if fillchar else " " if align == 'l': @@ -129,7 +129,7 @@ def pad(text, width=_DEFAULT_WIDTH, align="c", fillchar=" "): return text.center(width, fillchar) -def crop(text, width=_DEFAULT_WIDTH, suffix="[...]"): +def crop(text, width=None, suffix="[...]"): """ Crop text to a certain width, throwing away text from too-long lines. @@ -147,7 +147,7 @@ def crop(text, width=_DEFAULT_WIDTH, suffix="[...]"): text (str): The cropped text. """ - + width = width if width else settings.CLIENT_DEFAULT_WIDTH utext = to_unicode(text) ltext = len(utext) if ltext <= width: @@ -179,7 +179,7 @@ def dedent(text): return textwrap.dedent(text) -def justify(text, width=_DEFAULT_WIDTH, align="f", indent=0): +def justify(text, width=None, align="f", indent=0): """ Fully justify a text so that it fits inside `width`. When using full justification (default) this will be done by padding between @@ -198,6 +198,7 @@ def justify(text, width=_DEFAULT_WIDTH, align="f", indent=0): justified (str): The justified and indented block of text. """ + width = width if width else settings.CLIENT_DEFAULT_WIDTH def _process_line(line): """ From a6d08022025f2e986ee7bb25506ed6819c485b71 Mon Sep 17 00:00:00 2001 From: Tehom Date: Tue, 19 Jun 2018 04:12:46 -0400 Subject: [PATCH 150/208] Move query/unpickling out of loop for mutelist. --- evennia/comms/comms.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/evennia/comms/comms.py b/evennia/comms/comms.py index e40de664d1..32ea2731a1 100644 --- a/evennia/comms/comms.py +++ b/evennia/comms/comms.py @@ -91,7 +91,8 @@ class DefaultChannel(with_metaclass(TypeclassBase, ChannelDB)): @property def wholist(self): subs = self.subscriptions.all() - listening = [ob for ob in subs if ob.is_connected and ob not in self.mutelist] + muted = list(self.mutelist) + listening = [ob for ob in subs if ob.is_connected and ob not in muted] if subs: # display listening subscribers in bold string = ", ".join([account.key if account not in listening else "|w%s|n" % account.key for account in subs]) From 0100a7597773d3254b2681d45df4ac8a74414cad Mon Sep 17 00:00:00 2001 From: Griatch Date: Tue, 19 Jun 2018 21:44:20 +0200 Subject: [PATCH 151/208] Testing obj conversion in profuncs --- evennia/prototypes/protfuncs.py | 33 ++++++++++++++++---------------- evennia/prototypes/prototypes.py | 23 ++++++++++++++++++++++ evennia/prototypes/tests.py | 2 ++ 3 files changed, 41 insertions(+), 17 deletions(-) diff --git a/evennia/prototypes/protfuncs.py b/evennia/prototypes/protfuncs.py index 5ecb4b5e7d..4c9d9a4a5f 100644 --- a/evennia/prototypes/protfuncs.py +++ b/evennia/prototypes/protfuncs.py @@ -248,27 +248,21 @@ def eval(*args, **kwargs): if isinstance(struct, basestring): # we must shield the string, otherwise it will be merged as a string and future - # literal_evals will pick up e.g. '2' as something that should be converted to a number + # literal_evas will pick up e.g. '2' as something that should be converted to a number struct = '"{}"'.format(struct) - def _recursive_parse(val): - # an extra round of recursive parsing after literal_eval, to catch any - # escaped $$profuncs. This is commonly useful for object references. - if is_iter(val): - stype = type(val) - if stype == dict: - return {_recursive_parse(key): _recursive_parse(v) for key, v in val.items()} - return stype((_recursive_parse(v) for v in val)) - return _PROTLIB.protfunc_parser(val) + # convert any #dbrefs to objects (also in nested structures) + struct = _PROTLIB.value_to_obj_or_any(struct) - return _recursive_parse(struct) + return struct -def _obj_search(return_list=False, *args, **kwargs): +def _obj_search(*args, **kwargs): "Helper function to search for an object" query = "".join(args) session = kwargs.get("session", None) + return_list = kwargs.pop("return_list", False) if not session: raise ValueError("$obj called by Evennia without Session. This is not supported.") @@ -277,6 +271,8 @@ def _obj_search(return_list=False, *args, **kwargs): raise ValueError("$obj requires a logged-in account session.") targets = search.search_object(query) + print("targets: {}".format(targets)) + if return_list: retlist = [] for target in targets: @@ -287,11 +283,11 @@ def _obj_search(return_list=False, *args, **kwargs): # single-match if not targets: raise ValueError("$obj: Query '{}' gave no matches.".format(query)) - if targets.count() > 1: + if len(targets) > 1: raise ValueError("$obj: Query '{query}' gave {nmatches} matches. Limit your " "query or use $objlist instead.".format( - query=query, nmatches=targets.count())) - target = target[0] + query=query, nmatches=len(targets))) + target = targets[0] if not target.access(account, target, 'control'): raise ValueError("$obj: Obj {target}(#{dbref} cannot be added - " "Account {account} does not have 'control' access.".format( @@ -305,7 +301,10 @@ def obj(*args, **kwargs): Returns one Object searched globally by key, alias or #dbref. Error if more than one. """ - return _obj_search(*args, **kwargs) + obj = _obj_search(return_list=False, *args, **kwargs) + if obj: + return "#{}".format(obj.id) + return "".join(args) def objlist(*args, **kwargs): @@ -314,4 +313,4 @@ def objlist(*args, **kwargs): Returns list with one or more Objects searched globally by key, alias or #dbref. """ - return _obj_search(return_list=True, *args, **kwargs) + return ["#{}".format(obj.id) for obj in _obj_search(return_list=True, *args, **kwargs)] diff --git a/evennia/prototypes/prototypes.py b/evennia/prototypes/prototypes.py index 86230354b9..81bb4188a2 100644 --- a/evennia/prototypes/prototypes.py +++ b/evennia/prototypes/prototypes.py @@ -5,6 +5,7 @@ Handling storage of prototypes, both database-based ones (DBPrototypes) and thos """ +import re from ast import literal_eval from django.conf import settings from evennia.scripts.scripts import DefaultScript @@ -26,6 +27,9 @@ _PROTOTYPE_TAG_META_CATEGORY = "db_prototype" _PROT_FUNCS = {} +_RE_DBREF = re.compile(r"(? {}".format(value, available_functions, result)) result = value_to_obj_or_any(result) @@ -102,10 +111,24 @@ def protfunc_parser(value, available_functions=None, testing=False, **kwargs): # helper functions def value_to_obj(value, force=True): + "Always convert value(s) to Object, or None" + stype = type(value) + if is_iter(value): + if stype == dict: + return {value_to_obj_or_any(key): value_to_obj_or_any(val) for key, val in value.iter()} + else: + return stype([value_to_obj_or_any(val) for val in value]) return dbid_to_obj(value, ObjectDB) def value_to_obj_or_any(value): + "Convert value(s) to Object if possible, otherwise keep original value" + stype = type(value) + if is_iter(value): + if stype == dict: + return {value_to_obj_or_any(key): value_to_obj_or_any(val) for key, val in value.items()} + else: + return stype([value_to_obj_or_any(val) for val in value]) obj = dbid_to_obj(value, ObjectDB) return obj if obj is not None else value diff --git a/evennia/prototypes/tests.py b/evennia/prototypes/tests.py index 36be5f4c6b..c49292bbf5 100644 --- a/evennia/prototypes/tests.py +++ b/evennia/prototypes/tests.py @@ -226,6 +226,8 @@ class TestProtFuncs(EvenniaTest): self.assertEqual(protlib.protfunc_parser( "$eval({'test': '1', 2:3, 3: $toint(3.5)})"), {'test': '1', 2: 3, 3: 3}) + self.assertEqual(protlib.protfunc_parser("$obj(#1)", session=self.session), '#1') + class TestPrototypeStorage(EvenniaTest): From 2fcf6d164026e5cfa803db85857e04d5af58b212 Mon Sep 17 00:00:00 2001 From: Griatch Date: Thu, 21 Jun 2018 22:42:50 +0200 Subject: [PATCH 152/208] Unittests pass for all protfuncs --- evennia/prototypes/protfuncs.py | 31 ++++++++++++++++--------------- evennia/prototypes/prototypes.py | 11 +++++------ evennia/prototypes/tests.py | 16 ++++++++++++++-- evennia/utils/inlinefuncs.py | 22 +++++++++++++++++----- 4 files changed, 52 insertions(+), 28 deletions(-) diff --git a/evennia/prototypes/protfuncs.py b/evennia/prototypes/protfuncs.py index 4c9d9a4a5f..6dff62ef96 100644 --- a/evennia/prototypes/protfuncs.py +++ b/evennia/prototypes/protfuncs.py @@ -263,21 +263,21 @@ def _obj_search(*args, **kwargs): query = "".join(args) session = kwargs.get("session", None) return_list = kwargs.pop("return_list", False) + account = None + + if session: + account = session.account - if not session: - raise ValueError("$obj called by Evennia without Session. This is not supported.") - account = session.account - if not account: - raise ValueError("$obj requires a logged-in account session.") targets = search.search_object(query) - print("targets: {}".format(targets)) - if return_list: retlist = [] - for target in targets: - if target.access(account, target, 'control'): - retlist.append(target) + if account: + for target in targets: + if target.access(account, target, 'control'): + retlist.append(target) + else: + retlist = targets return retlist else: # single-match @@ -288,11 +288,12 @@ def _obj_search(*args, **kwargs): "query or use $objlist instead.".format( query=query, nmatches=len(targets))) target = targets[0] - if not target.access(account, target, 'control'): - raise ValueError("$obj: Obj {target}(#{dbref} cannot be added - " - "Account {account} does not have 'control' access.".format( - target=target.key, dbref=target.id, account=account)) - return target + if account: + if not target.access(account, target, 'control'): + raise ValueError("$obj: Obj {target}(#{dbref} cannot be added - " + "Account {account} does not have 'control' access.".format( + target=target.key, dbref=target.id, account=account)) + return target def obj(*args, **kwargs): diff --git a/evennia/prototypes/prototypes.py b/evennia/prototypes/prototypes.py index 81bb4188a2..ac343b3ec6 100644 --- a/evennia/prototypes/prototypes.py +++ b/evennia/prototypes/prototypes.py @@ -27,7 +27,7 @@ _PROTOTYPE_TAG_META_CATEGORY = "db_prototype" _PROT_FUNCS = {} -_RE_DBREF = re.compile(r"(? {}".format(value, available_functions, result)) - result = value_to_obj_or_any(result) err = None try: result = literal_eval(result) diff --git a/evennia/prototypes/tests.py b/evennia/prototypes/tests.py index c49292bbf5..0eeb236fb2 100644 --- a/evennia/prototypes/tests.py +++ b/evennia/prototypes/tests.py @@ -5,10 +5,10 @@ Unit tests for the prototypes and spawner from random import randint import mock -from anything import Anything, Something +from anything import Something from django.test.utils import override_settings from evennia.utils.test_resources import EvenniaTest -from evennia.prototypes import spawner, prototypes as protlib, protfuncs +from evennia.prototypes import spawner, prototypes as protlib from evennia.prototypes.prototypes import _PROTOTYPE_TAG_META_CATEGORY @@ -227,6 +227,18 @@ class TestProtFuncs(EvenniaTest): "$eval({'test': '1', 2:3, 3: $toint(3.5)})"), {'test': '1', 2: 3, 3: 3}) self.assertEqual(protlib.protfunc_parser("$obj(#1)", session=self.session), '#1') + self.assertEqual(protlib.protfunc_parser("#1", session=self.session), '#1') + self.assertEqual(protlib.protfunc_parser("$obj(Char)", session=self.session), '#6') + self.assertEqual(protlib.protfunc_parser("$obj(Char)", session=self.session), '#6') + self.assertEqual(protlib.protfunc_parser("$objlist(#1)", session=self.session), ['#1']) + + self.assertEqual(protlib.value_to_obj( + protlib.protfunc_parser("#6", session=self.session)), self.char1) + self.assertEqual(protlib.value_to_obj_or_any( + protlib.protfunc_parser("#6", session=self.session)), self.char1) + self.assertEqual(protlib.value_to_obj_or_any( + protlib.protfunc_parser("[1,2,3,'#6',5]", session=self.session)), + [1, 2, 3, self.char1, 5]) class TestPrototypeStorage(EvenniaTest): diff --git a/evennia/utils/inlinefuncs.py b/evennia/utils/inlinefuncs.py index f60f9f0d8a..d62493c786 100644 --- a/evennia/utils/inlinefuncs.py +++ b/evennia/utils/inlinefuncs.py @@ -63,7 +63,8 @@ Error handling: import re import fnmatch from django.conf import settings -from evennia.utils import utils + +from evennia.utils import utils, logger # example/testing inline functions @@ -264,7 +265,7 @@ class InlinefuncError(RuntimeError): pass -def parse_inlinefunc(string, strip=False, available_funcs=None, **kwargs): +def parse_inlinefunc(string, strip=False, available_funcs=None, stacktrace=False, **kwargs): """ Parse the incoming string. @@ -274,6 +275,7 @@ def parse_inlinefunc(string, strip=False, available_funcs=None, **kwargs): execute them. available_funcs (dict, optional): Define an alternative source of functions to parse for. If unset, use the functions found through `settings.INLINEFUNC_MODULES`. + stacktrace (bool, optional): If set, print the stacktrace to log. Kwargs: session (Session): This is sent to this function by Evennia when triggering it. It is passed to the inlinefunc. @@ -307,12 +309,18 @@ def parse_inlinefunc(string, strip=False, available_funcs=None, **kwargs): ncallable = 0 nlparens = 0 - # print("STRING: {} =>".format(string)) + if stacktrace: + out = "STRING: {} =>".format(string) + print(out) + logger.log_info(out) for match in _RE_TOKEN.finditer(string): gdict = match.groupdict() - # print(" MATCH: {}".format({key: val for key, val in gdict.items() if val})) + if stacktrace: + out = " MATCH: {}".format({key: val for key, val in gdict.items() if val}) + print(out) + logger.log_info(out) if gdict["singlequote"]: stack.append(gdict["singlequote"]) @@ -399,7 +407,11 @@ def parse_inlinefunc(string, strip=False, available_funcs=None, **kwargs): retval = "" if strip else func(*args, **kwargs) return utils.to_str(retval, force_string=True) retval = "".join(_run_stack(item) for item in stack) - # print("STACK: \n{} => {}\n".format(stack, retval)) + if stacktrace: + out = "STACK: \n{} => {}\n".format(stack, retval) + print(out) + logger.log_info(out) + # execute the stack return retval From 19c9687f010e85427289c710c74ed309aa11ce7a Mon Sep 17 00:00:00 2001 From: Griatch Date: Sun, 24 Jun 2018 09:50:03 +0200 Subject: [PATCH 153/208] Rename prototype to prototype_parent, fixing olc menu --- evennia/commands/default/building.py | 4 +- evennia/prototypes/menus.py | 52 ++++++++------- evennia/prototypes/prototypes.py | 94 +++++++++++++++++++++------- evennia/prototypes/spawner.py | 25 ++++---- evennia/prototypes/tests.py | 36 +++++++++++ evennia/utils/tests/test_evmenu.py | 3 +- 6 files changed, 155 insertions(+), 59 deletions(-) diff --git a/evennia/commands/default/building.py b/evennia/commands/default/building.py index 301bd03761..5c96ad1cf6 100644 --- a/evennia/commands/default/building.py +++ b/evennia/commands/default/building.py @@ -13,7 +13,7 @@ from evennia.utils import create, utils, search from evennia.utils.utils import inherits_from, class_from_module, get_all_typeclasses from evennia.utils.eveditor import EvEditor from evennia.utils.evmore import EvMore -from evennia.prototypes import spawner, prototypes as protlib +from evennia.prototypes import spawner, prototypes as protlib, menus as olc_menus from evennia.utils.ansi import raw COMMAND_DEFAULT_CLASS = class_from_module(settings.COMMAND_DEFAULT_CLASS) @@ -2917,7 +2917,7 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): elif prototype: # one match prototype = prototype[0] - spawner.start_olc(caller, session=self.session, prototype=prototype) + olc_menus.start_olc(caller, session=self.session, prototype=prototype) return if 'search' in self.switches: diff --git a/evennia/prototypes/menus.py b/evennia/prototypes/menus.py index bebc6d00bd..ead299abc7 100644 --- a/evennia/prototypes/menus.py +++ b/evennia/prototypes/menus.py @@ -9,8 +9,8 @@ from django.conf import settings from evennia.utils.evmenu import EvMenu, list_node from evennia.utils.ansi import strip_ansi from evennia.utils import utils -from evennia.utils.prototypes import prototypes as protlib -from evennia.utils.prototypes import spawner +from evennia.prototypes import prototypes as protlib +from evennia.prototypes import spawner # ------------------------------------------------------------ # @@ -43,12 +43,6 @@ def _is_new_prototype(caller): return hasattr(caller.ndb._menutree, "olc_new") -def _set_menu_prototype(caller, field, value): - prototype = _get_menu_prototype(caller) - prototype[field] = value - caller.ndb._menutree.olc_prototype = prototype - - def _format_property(prop, required=False, prototype=None, cropper=None): if prototype is not None: @@ -67,6 +61,13 @@ def _format_property(prop, required=False, prototype=None, cropper=None): return " ({}|n)".format(cropper(out) if cropper else utils.crop(out, _MENU_CROP_WIDTH)) +def _set_prototype_value(caller, field, value): + prototype = _get_menu_prototype(caller) + prototype[field] = value + caller.ndb._menutree.olc_prototype = prototype + return prototype + + def _set_property(caller, raw_string, **kwargs): """ Update a property. To be called by the 'goto' option variable. @@ -102,22 +103,26 @@ def _set_property(caller, raw_string, **kwargs): if not value: return next_node - prototype = _get_menu_prototype(caller) + prototype = _set_prototype_value(caller, "prototype_key", value) - # typeclass and prototype can't co-exist + # typeclass and prototype_parent can't co-exist if propname_low == "typeclass": - prototype.pop("prototype", None) - if propname_low == "prototype": + prototype.pop("prototype_parent", None) + if propname_low == "prototype_parent": prototype.pop("typeclass", None) caller.ndb._menutree.olc_prototype = prototype - caller.msg("Set {prop} to '{value}'.".format(prop, value=str(value))) + caller.msg("Set {prop} to '{value}'.".format(prop=prop, value=str(value))) return next_node def _wizard_options(curr_node, prev_node, next_node, color="|W"): + """ + Creates default navigation options available in the wizard. + + """ options = [] if prev_node: options.append({"key": ("|wb|Wack", "b"), @@ -154,8 +159,8 @@ def node_index(caller): text = ("|c --- Prototype wizard --- |n\n\n" "Define the |yproperties|n of the prototype. All prototype values can be " "over-ridden at the time of spawning an instance of the prototype, but some are " - "required.\n\n'|wMeta'-properties|n are not used in the prototype itself but are used " - "to organize and list prototypes. The 'Meta-Key' uniquely identifies the prototype " + "required.\n\n'|wprototype-'-properties|n are not used in the prototype itself but are used " + "to organize and list prototypes. The 'prototype-key' uniquely identifies the prototype " "and allows you to edit an existing prototype or save a new one for use by you or " "others later.\n\n(make choice; q to abort. If unsure, start from 1.)") @@ -192,9 +197,12 @@ def node_validate_prototype(caller, raw_string, **kwargs): errors = "\n\n|g No validation errors found.|n (but errors could still happen at spawn-time)" try: # validate, don't spawn - spawner.spawn(prototype, return_prototypes=True) + spawner.spawn(prototype, only_validate=True) except RuntimeError as err: - errors = "\n\n|rError: {}|n".format(err) + errors = "\n\n|r{}|n".format(err) + except RuntimeWarning as err: + errors = "\n\n|y{}|n".format(err) + text = (txt + errors) options = _wizard_options(None, kwargs.get("back"), None) @@ -287,7 +295,9 @@ def node_prototype(caller): def _all_typeclasses(caller): - return list(sorted(utils.get_all_typeclasses().keys())) + return list(name for name in + sorted(utils.get_all_typeclasses("evennia.objects.models.ObjectDB").keys()) + if name != "evennia.objects.models.ObjectDB") def _typeclass_examine(caller, typeclass_path): @@ -403,7 +413,7 @@ def _add_attr(caller, attr_string, **kwargs): if attrname: prot = _get_menu_prototype(caller) prot['attrs'][attrname] = value - _set_menu_prototype(caller, "prototype", prot) + _set_prototype_value(caller, "prototype", prot) text = "Added" else: text = "Attribute must be given as 'attrname = ' where uses valid Python." @@ -468,7 +478,7 @@ def _add_tag(caller, tag, **kwargs): else: tags = [tag] prototype['tags'] = tags - _set_menu_prototype(caller, "prototype", prototype) + _set_prototype_value(caller, "prototype", prototype) text = kwargs.get("text") if not text: text = "Added tag {}. (return to continue)".format(tag) @@ -485,7 +495,7 @@ def _edit_tag(caller, old_tag, new_tag, **kwargs): new_tag = new_tag.strip().lower() tags[tags.index(old_tag)] = new_tag prototype['tags'] = tags - _set_menu_prototype(caller, 'prototype', prototype) + _set_prototype_value(caller, 'prototype', prototype) text = kwargs.get('text') if not text: diff --git a/evennia/prototypes/prototypes.py b/evennia/prototypes/prototypes.py index ac343b3ec6..18516681b2 100644 --- a/evennia/prototypes/prototypes.py +++ b/evennia/prototypes/prototypes.py @@ -12,7 +12,8 @@ from evennia.scripts.scripts import DefaultScript from evennia.objects.models import ObjectDB from evennia.utils.create import create_script from evennia.utils.utils import ( - all_from_module, make_iter, is_iter, dbid_to_obj, callables_from_module) + all_from_module, make_iter, is_iter, dbid_to_obj, callables_from_module, + get_all_typeclasses) from evennia.locks.lockhandler import validate_lockstring, check_lockstring from evennia.utils import logger from evennia.utils import inlinefuncs @@ -143,10 +144,10 @@ def prototype_to_str(prototype): header = ( "|cprototype key:|n {}, |ctags:|n {}, |clocks:|n {} \n" "|cdesc:|n {} \n|cprototype:|n ".format( - prototype['prototype_key'], - ", ".join(prototype['prototype_tags']), - prototype['prototype_locks'], - prototype['prototype_desc'])) + prototype.get('prototype_key', None), + ", ".join(prototype.get('prototype_tags', ['None'])), + prototype.get('prototype_locks', None), + prototype.get('prototype_desc', None))) proto = ("{{\n {} \n}}".format( "\n ".join( "{!r}: {!r},".format(key, value) for key, value in @@ -513,7 +514,8 @@ def list_prototypes(caller, key=None, tags=None, show_non_use=False, show_non_ed return table -def validate_prototype(prototype, protkey=None, protparents=None, _visited=None): +def validate_prototype(prototype, protkey=None, protparents=None, + is_prototype_base=True, _flags=None): """ Run validation on a prototype, checking for inifinite regress. @@ -523,33 +525,77 @@ def validate_prototype(prototype, protkey=None, protparents=None, _visited=None) dict needs to have the `prototype_key` field set. protpartents (dict, optional): The available prototype parent library. If note given this will be determined from settings/database. - _visited (list, optional): This is an internal work array and should not be set manually. + is_prototype_base (bool, optional): We are trying to create a new object *based on this + object*. This means we can't allow 'mixin'-style prototypes without typeclass/parent + etc. + _flags (dict, optional): Internal work dict that should not be set externally. Raises: RuntimeError: If prototype has invalid structure. + RuntimeWarning: If prototype has issues that would make it unsuitable to build an object + with (it may still be useful as a mix-in prototype). """ + assert isinstance(prototype, dict) + + if _flags is None: + _flags = {"visited": [], "depth": 0, "typeclass": False, "errors": [], "warnings": []} + if not protparents: protparents = {prototype['prototype_key']: prototype for prototype in search_prototype()} - if _visited is None: - _visited = [] protkey = protkey and protkey.lower() or prototype.get('prototype_key', None) - assert isinstance(prototype, dict) + if not bool(protkey): + _flags['errors'].append("Prototype lacks a `prototype_key`.") + protkey = "[UNSET]" - if id(prototype) in _visited: - raise RuntimeError("%s has infinite nesting of prototypes." % protkey or prototype) + typeclass = prototype.get('typeclass') + prototype_parent = prototype.get('prototype_parent', []) - _visited.append(id(prototype)) - protstrings = prototype.get("prototype") + if not (typeclass or prototype_parent): + if is_prototype_base: + _flags['errors'].append("Prototype {} requires `typeclass` " + "or 'prototype_parent'.".format(protkey)) + else: + _flags['warnings'].append("Prototype {} can only be used as a mixin since it lacks " + "a typeclass or a prototype_parent.".format(protkey)) - if protstrings: - for protstring in make_iter(protstrings): - protstring = protstring.lower() - if protkey is not None and protstring == protkey: - raise RuntimeError("%s tries to prototype itself." % protkey or prototype) - protparent = protparents.get(protstring) - if not protparent: - raise RuntimeError( - "%s's prototype '%s' was not found." % (protkey or prototype, protstring)) - validate_prototype(protparent, protstring, protparents, _visited) + if typeclass and typeclass not in get_all_typeclasses("evennia.objects.models.ObjectDB"): + _flags['errors'].append( + "Prototype {} is based on typeclass {} which could not be imported!".format( + protkey, typeclass)) + + # recursively traverese prototype_parent chain + + if id(prototype) in _flags['visited']: + _flags['errors'].append( + "{} has infinite nesting of prototypes.".format(protkey or prototype)) + + _flags['visited'].append(id(prototype)) + + for protstring in make_iter(prototype_parent): + protstring = protstring.lower() + if protkey is not None and protstring == protkey: + _flags['errors'].append("Protototype {} tries to parent itself.".format(protkey)) + protparent = protparents.get(protstring) + if not protparent: + _flags['errors'].append("Prototype {}'s prototype_parent '{}' was not found.".format( + (protkey, protstring))) + _flags['depth'] += 1 + validate_prototype(protparent, protstring, protparents, _flags) + _flags['depth'] -= 1 + + if typeclass and not _flags['typeclass']: + _flags['typeclass'] = typeclass + + # if we get back to the current level without a typeclass it's an error. + if is_prototype_base and _flags['depth'] <= 0 and not _flags['typeclass']: + _flags['errors'].append("Prototype {} has no `typeclass` defined anywhere in its parent " + "chain. Add `typeclass`, or a `prototype_parent` pointing to a " + "prototype with a typeclass.".format(protkey)) + + if _flags['depth'] <= 0: + if _flags['errors']: + raise RuntimeError("Error: " + "\nError: ".join(_flags['errors'])) + if _flags['warnings']: + raise RuntimeWarning("Warning: " + "\nWarning: ".join(_flags['warnings'])) diff --git a/evennia/prototypes/spawner.py b/evennia/prototypes/spawner.py index da4d69eeb4..df07e3b155 100644 --- a/evennia/prototypes/spawner.py +++ b/evennia/prototypes/spawner.py @@ -32,7 +32,7 @@ Possible keywords are: prototype_tags(list, optional): List of tags or tuples (tag, category) used to group prototype in listings - parent (str, tuple or callable, optional): name (prototype_key) of eventual parent prototype, or + prototype_parent (str, tuple or callable, optional): name (prototype_key) of eventual parent prototype, or a list of parents, for multiple left-to-right inheritance. prototype: Deprecated. Same meaning as 'parent'. typeclass (str or callable, optional): if not set, will use typeclass of parent prototype or use @@ -75,13 +75,13 @@ import random GOBLIN_WIZARD = { - "parent": GOBLIN, + "prototype_parent": GOBLIN, "key": "goblin wizard", "spells": ["fire ball", "lighting bolt"] } GOBLIN_ARCHER = { - "parent": GOBLIN, + "prototype_parent": GOBLIN, "key": "goblin archer", "attack_skill": (random, (5, 10))" "attacks": ["short bow"] @@ -97,7 +97,7 @@ ARCHWIZARD = { GOBLIN_ARCHWIZARD = { "key" : "goblin archwizard" - "parent": (GOBLIN_WIZARD, ARCHWIZARD), + "prototype_parent": (GOBLIN_WIZARD, ARCHWIZARD), } ``` @@ -460,11 +460,15 @@ def spawn(*prototypes, **kwargs): prototype_parents (dict): A dictionary holding a custom prototype-parent dictionary. Will overload same-named prototypes from prototype_modules. - return_prototypes (bool): Only return a list of the + return_parents (bool): Only return a dict of the prototype-parents (no object creation happens) + only_validate (bool): Only run validation of prototype/parents + (no object creation) and return the create-kwargs. Returns: - object (Object): Spawned object. + object (Object, dict or list): Spawned object. If `only_validate` is given, return + a list of the creation kwargs to build the object(s) without actually creating it. If + `return_parents` is set, return dict of prototype parents. """ # get available protparents @@ -474,17 +478,14 @@ def spawn(*prototypes, **kwargs): protparents.update( {key.lower(): value for key, value in kwargs.get("prototype_parents", {}).items()}) - for key, prototype in protparents.items(): - protlib.validate_prototype(prototype, key.lower(), protparents) - - if "return_prototypes" in kwargs: + if "return_parents" in kwargs: # only return the parents return copy.deepcopy(protparents) objsparams = [] for prototype in prototypes: - protlib.validate_prototype(prototype, None, protparents) + protlib.validate_prototype(prototype, None, protparents, is_prototype_base=True) prot = _get_prototype(prototype, {}, protparents) if not prot: continue @@ -556,4 +557,6 @@ def spawn(*prototypes, **kwargs): objsparams.append((create_kwargs, permission_string, lock_string, alias_string, nattributes, attributes, tags, execs)) + if kwargs.get("only_validate"): + return objsparams return batch_create_object(*objsparams) diff --git a/evennia/prototypes/tests.py b/evennia/prototypes/tests.py index 0eeb236fb2..0f48c3780a 100644 --- a/evennia/prototypes/tests.py +++ b/evennia/prototypes/tests.py @@ -8,7 +8,9 @@ import mock from anything import Something from django.test.utils import override_settings from evennia.utils.test_resources import EvenniaTest +from evennia.utils.tests.test_evmenu import TestEvMenu from evennia.prototypes import spawner, prototypes as protlib +from evennia.prototypes import menus as olc_menus from evennia.prototypes.prototypes import _PROTOTYPE_TAG_META_CATEGORY @@ -304,3 +306,37 @@ class TestPrototypeStorage(EvenniaTest): self.assertEqual(list(protlib.search_prototype(tags="foo1")), [prot1b, prot2, prot3]) self.assertTrue(str(unicode(protlib.list_prototypes(self.char1)))) + + +@mock.patch("evennia.prototypes.menus.protlib.search_prototype", new=mock.MagicMock( + return_value=[{"prototype_key": "TestPrototype", + "typeclass": "TypeClassTest", "key": "TestObj"}])) +@mock.patch("evennia.utils.utils.get_all_typeclasses", new=mock.MagicMock( + return_value={"TypeclassTest": None})) +class TestOLCMenu(TestEvMenu): + + maxDiff = None + menutree = "evennia.prototypes.menus" + startnode = "node_index" + + debug_output = True + + expected_node_texts = { + "node_index": "|c --- Prototype wizard --- |n" + } + + expected_tree = \ + ['node_index', + ['node_prototype_key', + 'node_typeclass', + 'node_aliases', + 'node_attrs', + 'node_tags', + 'node_locks', + 'node_permissions', + 'node_location', + 'node_home', + 'node_destination', + 'node_prototype_desc', + 'node_prototype_tags', + 'node_prototype_locks']] diff --git a/evennia/utils/tests/test_evmenu.py b/evennia/utils/tests/test_evmenu.py index 04310c90ed..d3ee14a74f 100644 --- a/evennia/utils/tests/test_evmenu.py +++ b/evennia/utils/tests/test_evmenu.py @@ -58,7 +58,7 @@ class TestEvMenu(TestCase): def _debug_output(self, indent, msg): if self.debug_output: - print(" " * indent + msg) + print(" " * indent + ansi.strip_ansi(msg)) def _test_menutree(self, menu): """ @@ -168,6 +168,7 @@ class TestEvMenu(TestCase): self.caller2.msg = MagicMock() self.session = MagicMock() self.session2 = MagicMock() + self.menu = evmenu.EvMenu(self.caller, self.menutree, startnode=self.startnode, cmdset_mergetype=self.cmdset_mergetype, cmdset_priority=self.cmdset_priority, From 952a5a1ee3bac67216c8001d38758aa1fe48216f Mon Sep 17 00:00:00 2001 From: Griatch Date: Sun, 24 Jun 2018 16:03:48 +0200 Subject: [PATCH 154/208] Unit testing/debugging olc menu --- evennia/prototypes/menus.py | 94 ++++++++++++++++++++++---------- evennia/prototypes/prototypes.py | 6 +- evennia/prototypes/tests.py | 86 +++++++++++++++++++++++++++++ evennia/utils/inlinefuncs.py | 16 +++++- 4 files changed, 171 insertions(+), 31 deletions(-) diff --git a/evennia/prototypes/menus.py b/evennia/prototypes/menus.py index ead299abc7..ff38c3448e 100644 --- a/evennia/prototypes/menus.py +++ b/evennia/prototypes/menus.py @@ -29,7 +29,7 @@ _MENU_ATTR_LITERAL_EVAL_ERROR = ( def _get_menu_prototype(caller): - + """Return currently active menu prototype.""" prototype = None if hasattr(caller.ndb._menutree, "olc_prototype"): prototype = caller.ndb._menutree.olc_prototype @@ -40,11 +40,23 @@ def _get_menu_prototype(caller): def _is_new_prototype(caller): + """Check if prototype is marked as new or was loaded from a saved one.""" return hasattr(caller.ndb._menutree, "olc_new") -def _format_property(prop, required=False, prototype=None, cropper=None): +def _format_option_value(prop, required=False, prototype=None, cropper=None): + """ + Format wizard option values. + Args: + prop (str): Name or value to format. + required (bool, optional): The option is required. + prototype (dict, optional): If given, `prop` will be considered a key in this prototype. + cropper (callable, optional): A function to crop the value to a certain width. + + Returns: + value (str): The formatted value. + """ if prototype is not None: prop = prototype.get(prop, '') @@ -61,7 +73,8 @@ def _format_property(prop, required=False, prototype=None, cropper=None): return " ({}|n)".format(cropper(out) if cropper else utils.crop(out, _MENU_CROP_WIDTH)) -def _set_prototype_value(caller, field, value): +def _set_prototype_value(caller, field, value, parse=True): + """Set prototype's field in a safe way.""" prototype = _get_menu_prototype(caller) prototype[field] = value caller.ndb._menutree.olc_prototype = prototype @@ -70,15 +83,21 @@ def _set_prototype_value(caller, field, value): def _set_property(caller, raw_string, **kwargs): """ - Update a property. To be called by the 'goto' option variable. + Add or update a property. To be called by the 'goto' option variable. Args: caller (Object, Account): The user of the wizard. raw_string (str): Input from user on given node - the new value to set. + Kwargs: + test_parse (bool): If set (default True), parse raw_string for protfuncs and obj-refs and + try to run result through literal_eval. The parser will be run in 'testing' mode and any + parsing errors will shown to the user. Note that this is just for testing, the original + given string will be what is inserted. prop (str): Property name to edit with `raw_string`. processor (callable): Converts `raw_string` to a form suitable for saving. next_node (str): Where to redirect to after this has run. + Returns: next_node (str): Next node to go to. @@ -103,7 +122,7 @@ def _set_property(caller, raw_string, **kwargs): if not value: return next_node - prototype = _set_prototype_value(caller, "prototype_key", value) + prototype = _set_prototype_value(caller, prop, value) # typeclass and prototype_parent can't co-exist if propname_low == "typeclass": @@ -113,16 +132,26 @@ def _set_property(caller, raw_string, **kwargs): caller.ndb._menutree.olc_prototype = prototype - caller.msg("Set {prop} to '{value}'.".format(prop=prop, value=str(value))) + out = [" Set {prop} to {value} ({typ}).".format(prop=prop, value=value, typ=type(value))] + + if kwargs.get("test_parse", True): + out.append(" Simulating parsing ...") + err, parsed_value = protlib.protfunc_parser(value, testing=True) + if err: + out.append(" |yPython `literal_eval` warning: {}|n".format(err)) + if parsed_value != value: + out.append(" |g(Example-)value when parsed ({}):|n {}".format( + type(parsed_value), parsed_value)) + else: + out.append(" |gNo change.") + + caller.msg("\n".join(out)) return next_node def _wizard_options(curr_node, prev_node, next_node, color="|W"): - """ - Creates default navigation options available in the wizard. - - """ + """Creates default navigation options available in the wizard.""" options = [] if prev_node: options.append({"key": ("|wb|Wack", "b"), @@ -166,7 +195,7 @@ def node_index(caller): options = [] options.append( - {"desc": "|WPrototype-Key|n|n{}".format(_format_property("Key", True, prototype, None)), + {"desc": "|WPrototype-Key|n|n{}".format(_format_option_value("Key", True, prototype, None)), "goto": "node_prototype_key"}) for key in ('Prototype', 'Typeclass', 'Key', 'Aliases', 'Attrs', 'Tags', 'Locks', 'Permissions', 'Location', 'Home', 'Destination'): @@ -178,13 +207,13 @@ def node_index(caller): cropper = _path_cropper options.append( {"desc": "|w{}|n{}".format( - key, _format_property(key, required, prototype, cropper=cropper)), + key, _format_option_value(key, required, prototype, cropper=cropper)), "goto": "node_{}".format(key.lower())}) required = False for key in ('Desc', 'Tags', 'Locks'): options.append( {"desc": "|WPrototype-{}|n|n{}".format( - key, _format_property(key, required, prototype, None)), + key, _format_option_value(key, required, prototype, None)), "goto": "node_prototype_{}".format(key.lower())}) return text, options @@ -215,6 +244,7 @@ def _check_prototype_key(caller, key): olc_new = _is_new_prototype(caller) key = key.strip().lower() if old_prototype: + old_prototype = old_prototype[0] # we are starting a new prototype that matches an existing if not caller.locks.check_lockstring( caller, old_prototype['prototype_locks'], access_type='edit'): @@ -229,7 +259,7 @@ def _check_prototype_key(caller, key): caller.msg("Prototype already exists. Reloading.") return "node_index" - return _set_property(caller, key, prop='prototype_key', next_node="node_prototype") + return _set_property(caller, key, prop='prototype_key', next_node="node_prototype_parent") def node_prototype_key(caller): @@ -250,27 +280,32 @@ def node_prototype_key(caller): return text, options -def _all_prototypes(caller): +def _all_prototype_parents(caller): + """Return prototype_key of all available prototypes for listing in menu""" return [prototype["prototype_key"] for prototype in protlib.search_prototype() if "prototype_key" in prototype] -def _prototype_examine(caller, prototype_name): +def _prototype_parent_examine(caller, prototype_name): + """Convert prototype to a string representation for closer inspection""" prototypes = protlib.search_prototype(key=prototype_name) if prototypes: - caller.msg(protlib.prototype_to_str(prototypes[0])) - caller.msg("Prototype not registered.") - return None + ret = protlib.prototype_to_str(prototypes[0]) + caller.msg(ret) + return ret + else: + caller.msg("Prototype not registered.") -def _prototype_select(caller, prototype): - ret = _set_property(caller, prototype, prop="prototype", processor=str, next_node="node_key") +def _prototype_parent_select(caller, prototype): + ret = _set_property(caller, prototype['prototype_key'], + prop="prototype_parent", processor=str, next_node="node_key") caller.msg("Selected prototype |y{}|n. Removed any set typeclass parent.".format(prototype)) return ret -@list_node(_all_prototypes, _prototype_select) -def node_prototype(caller): +@list_node(_all_prototype_parents, _prototype_parent_select) +def node_prototype_parent(caller): prototype = _get_menu_prototype(caller) prot_parent_key = prototype.get('prototype') @@ -289,18 +324,20 @@ def node_prototype(caller): text = "\n\n".join(text) options = _wizard_options("prototype", "prototype_key", "typeclass", color="|W") options.append({"key": "_default", - "goto": _prototype_examine}) + "goto": _prototype_parent_examine}) return text, options def _all_typeclasses(caller): + """Get name of available typeclasses.""" return list(name for name in sorted(utils.get_all_typeclasses("evennia.objects.models.ObjectDB").keys()) if name != "evennia.objects.models.ObjectDB") def _typeclass_examine(caller, typeclass_path): + """Show info (docstring) about given typeclass.""" if typeclass_path is None: # this means we are exiting the listing return "node_key" @@ -319,10 +356,11 @@ def _typeclass_examine(caller, typeclass_path): else: txt = "This is typeclass |y{}|n.".format(typeclass) caller.msg(txt) - return None + return txt def _typeclass_select(caller, typeclass): + """Select typeclass from list and add it to prototype. Return next node to go to.""" ret = _set_property(caller, typeclass, prop='typeclass', processor=str, next_node="node_key") caller.msg("Selected typeclass |y{}|n. Removed any set prototype parent.".format(typeclass)) return ret @@ -350,7 +388,7 @@ def node_key(caller): prototype = _get_menu_prototype(caller) key = prototype.get("key") - text = ["Set the prototype's |yKey|n. This will retain case sensitivity."] + text = ["Set the prototype's name (|yKey|n.) This will retain case sensitivity."] if key: text.append("Current key value is '|y{key}|n'.".format(key=key)) else: @@ -370,7 +408,7 @@ def node_aliases(caller): aliases = prototype.get("aliases") text = ["Set the prototype's |yAliases|n. Separate multiple aliases with commas. " - "ill retain case sensitivity."] + "they'll retain case sensitivity."] if aliases: text.append("Current aliases are '|y{aliases}|n'.".format(aliases=aliases)) else: @@ -714,7 +752,7 @@ def start_olc(caller, session=None, prototype=None): menudata = {"node_index": node_index, "node_validate_prototype": node_validate_prototype, "node_prototype_key": node_prototype_key, - "node_prototype": node_prototype, + "node_prototype_parent": node_prototype_parent, "node_typeclass": node_typeclass, "node_key": node_key, "node_aliases": node_aliases, diff --git a/evennia/prototypes/prototypes.py b/evennia/prototypes/prototypes.py index 18516681b2..2ab3416afe 100644 --- a/evennia/prototypes/prototypes.py +++ b/evennia/prototypes/prototypes.py @@ -13,7 +13,7 @@ from evennia.objects.models import ObjectDB from evennia.utils.create import create_script from evennia.utils.utils import ( all_from_module, make_iter, is_iter, dbid_to_obj, callables_from_module, - get_all_typeclasses) + get_all_typeclasses, to_str) from evennia.locks.lockhandler import validate_lockstring, check_lockstring from evennia.utils import logger from evennia.utils import inlinefuncs @@ -64,6 +64,7 @@ def protfunc_parser(value, available_functions=None, testing=False, stacktrace=F value (any): The value to test for a parseable protfunc. Only strings will be parsed for protfuncs, all other types are returned as-is. available_functions (dict, optional): Mapping of name:protfunction to use for this parsing. + If not set, use default sources. testing (bool, optional): Passed to protfunc. If in a testing mode, some protfuncs may behave differently. stacktrace (bool, optional): If set, print the stack parsing process of the protfunc-parser. @@ -86,7 +87,8 @@ def protfunc_parser(value, available_functions=None, testing=False, stacktrace=F """ if not isinstance(value, basestring): - return value + value = to_str(value, force_string=True) + available_functions = _PROT_FUNCS if available_functions is None else available_functions # insert $obj(#dbref) for #dbref diff --git a/evennia/prototypes/tests.py b/evennia/prototypes/tests.py index 0f48c3780a..49624905c7 100644 --- a/evennia/prototypes/tests.py +++ b/evennia/prototypes/tests.py @@ -308,6 +308,91 @@ class TestPrototypeStorage(EvenniaTest): self.assertTrue(str(unicode(protlib.list_prototypes(self.char1)))) +class _MockMenu(object): + pass + + +class TestMenuModule(EvenniaTest): + + def setUp(self): + super(TestMenuModule, self).setUp() + + # set up fake store + self.caller = self.char1 + menutree = _MockMenu() + self.caller.ndb._menutree = menutree + + self.test_prot = {"prototype_key": "test_prot", + "prototype_locks": "edit:all();spawn:all()"} + + def test_helpers(self): + + caller = self.caller + + # general helpers + + self.assertEqual(olc_menus._get_menu_prototype(caller), {}) + self.assertEqual(olc_menus._is_new_prototype(caller), True) + + self.assertEqual( + olc_menus._set_prototype_value(caller, "key", "TestKey"), {"key": "TestKey"}) + self.assertEqual(olc_menus._get_menu_prototype(caller), {"key": "TestKey"}) + + self.assertEqual(olc_menus._format_option_value( + "key", required=True, prototype=olc_menus._get_menu_prototype(caller)), " (TestKey|n)") + self.assertEqual(olc_menus._format_option_value( + [1, 2, 3, "foo"], required=True), ' (1, 2, 3, foo|n)') + + self.assertEqual(olc_menus._set_property( + caller, "ChangedKey", prop="key", processor=str, next_node="foo"), "foo") + self.assertEqual(olc_menus._get_menu_prototype(caller), {"key": "ChangedKey"}) + + self.assertEqual(olc_menus._wizard_options( + "ThisNode", "PrevNode", "NextNode"), + [{'goto': 'node_PrevNode', 'key': ('|wb|Wack', 'b'), 'desc': '|W(PrevNode)|n'}, + {'goto': 'node_NextNode', 'key': ('|wf|Worward', 'f'), 'desc': '|W(NextNode)|n'}, + {'goto': 'node_index', 'key': ('|wi|Wndex', 'i')}, + {'goto': ('node_validate_prototype', {'back': 'ThisNode'}), + 'key': ('|wv|Walidate prototype', 'v')}]) + + def test_node_helpers(self): + + caller = self.caller + + with mock.patch("evennia.prototypes.menus.protlib.search_prototype", + new=mock.MagicMock(return_value=[self.test_prot])): + self.assertEqual(olc_menus._check_prototype_key(caller, "test_prot"), + "node_prototype_parent") + caller.ndb._menutree.olc_new = True + self.assertEqual(olc_menus._check_prototype_key(caller, "test_prot"), + "node_index") + + self.assertEqual(olc_menus._all_prototype_parents(caller), ['test_prot']) + self.assertEqual(olc_menus._prototype_parent_examine( + caller, 'test_prot'), + '|cprototype key:|n test_prot, |ctags:|n None, |clocks:|n edit:all();spawn:all() ' + '\n|cdesc:|n None \n|cprototype:|n {\n \n}') + self.assertEqual(olc_menus._prototype_parent_select(caller, self.test_prot), "node_key") + self.assertEqual(olc_menus._get_menu_prototype(caller), + {'prototype_key': 'test_prot', + 'prototype_locks': 'edit:all();spawn:all()', + 'prototype_parent': "test_prot"}) + + with mock.patch("evennia.utils.utils.get_all_typeclasses", + new=mock.MagicMock(return_value={"foo": None, "bar": None})): + self.assertEqual(olc_menus._all_typeclasses(caller), ["bar", "foo"]) + self.assertTrue(olc_menus._typeclass_examine( + caller, "evennia.objects.objects.DefaultObject").startswith("Typeclass |y")) + + self.assertEqual(olc_menus._typeclass_select( + caller, "evennia.objects.objects.DefaultObject"), "node_key") + # prototype_parent should be popped off here + self.assertEqual(olc_menus._get_menu_prototype(caller), + {'prototype_key': 'test_prot', + 'prototype_locks': 'edit:all();spawn:all()', + 'typeclass': 'evennia.objects.objects.DefaultObject'}) + + @mock.patch("evennia.prototypes.menus.protlib.search_prototype", new=mock.MagicMock( return_value=[{"prototype_key": "TestPrototype", "typeclass": "TypeClassTest", "key": "TestObj"}])) @@ -320,6 +405,7 @@ class TestOLCMenu(TestEvMenu): startnode = "node_index" debug_output = True + expect_all_nodes = True expected_node_texts = { "node_index": "|c --- Prototype wizard --- |n" diff --git a/evennia/utils/inlinefuncs.py b/evennia/utils/inlinefuncs.py index d62493c786..85ceeadc8a 100644 --- a/evennia/utils/inlinefuncs.py +++ b/evennia/utils/inlinefuncs.py @@ -162,6 +162,20 @@ def clr(*args, **kwargs): def null(*args, **kwargs): return args[0] if args else '' + +def nomatch(name, *args, **kwargs): + """ + Default implementation of nomatch returns the function as-is as a string. + + """ + kwargs.pop("inlinefunc_stack_depth", None) + kwargs.pop("session") + + return "${name}({args}{kwargs})".format( + name=name, + args=",".join(args), + kwargs=",".join("{}={}".format(key, val) for key, val in kwargs.items())) + _INLINE_FUNCS = {} # we specify a default nomatch function to use if no matching func was @@ -284,7 +298,6 @@ def parse_inlinefunc(string, strip=False, available_funcs=None, stacktrace=False """ global _PARSING_CACHE - usecache = False if not available_funcs: available_funcs = _INLINE_FUNCS @@ -357,6 +370,7 @@ def parse_inlinefunc(string, strip=False, available_funcs=None, stacktrace=False except KeyError: stack.append(available_funcs["nomatch"]) stack.append(funcname) + stack.append(None) ncallable += 1 elif gdict["escaped"]: # escaped tokens From a0d34c72230a5240a8915568cfb4db49845ead28 Mon Sep 17 00:00:00 2001 From: Griatch Date: Wed, 27 Jun 2018 00:13:19 +0200 Subject: [PATCH 155/208] Start with final load/save/spawn nodes of menu --- evennia/prototypes/menus.py | 250 +++++++++++++++++++++---------- evennia/prototypes/prototypes.py | 6 +- evennia/prototypes/spawner.py | 2 +- 3 files changed, 179 insertions(+), 79 deletions(-) diff --git a/evennia/prototypes/menus.py b/evennia/prototypes/menus.py index ff38c3448e..80e34e4c21 100644 --- a/evennia/prototypes/menus.py +++ b/evennia/prototypes/menus.py @@ -4,6 +4,7 @@ OLC Prototype menu nodes """ +import json from ast import literal_eval from django.conf import settings from evennia.utils.evmenu import EvMenu, list_node @@ -132,10 +133,16 @@ def _set_property(caller, raw_string, **kwargs): caller.ndb._menutree.olc_prototype = prototype - out = [" Set {prop} to {value} ({typ}).".format(prop=prop, value=value, typ=type(value))] + try: + # TODO simple way to get rid of the u'' markers in list reprs, remove this when on py3. + repr_value = json.dumps(value) + except Exception: + repr_value = value + + out = [" Set {prop} to {value} ({typ}).".format(prop=prop, value=repr_value, typ=type(value))] if kwargs.get("test_parse", True): - out.append(" Simulating parsing ...") + out.append(" Simulating prototype-func parsing ...") err, parsed_value = protlib.protfunc_parser(value, testing=True) if err: out.append(" |yPython `literal_eval` warning: {}|n".format(err)) @@ -143,7 +150,7 @@ def _set_property(caller, raw_string, **kwargs): out.append(" |g(Example-)value when parsed ({}):|n {}".format( type(parsed_value), parsed_value)) else: - out.append(" |gNo change.") + out.append(" |gNo change when parsed.") caller.msg("\n".join(out)) @@ -185,23 +192,24 @@ def _path_cropper(pythonpath): def node_index(caller): prototype = _get_menu_prototype(caller) - text = ("|c --- Prototype wizard --- |n\n\n" - "Define the |yproperties|n of the prototype. All prototype values can be " - "over-ridden at the time of spawning an instance of the prototype, but some are " - "required.\n\n'|wprototype-'-properties|n are not used in the prototype itself but are used " - "to organize and list prototypes. The 'prototype-key' uniquely identifies the prototype " - "and allows you to edit an existing prototype or save a new one for use by you or " - "others later.\n\n(make choice; q to abort. If unsure, start from 1.)") + text = ( + "|c --- Prototype wizard --- |n\n\n" + "Define the |yproperties|n of the prototype. All prototype values can be " + "over-ridden at the time of spawning an instance of the prototype, but some are " + "required.\n\n'|wprototype-'-properties|n are not used in the prototype itself but are used " + "to organize and list prototypes. The 'prototype-key' uniquely identifies the prototype " + "and allows you to edit an existing prototype or save a new one for use by you or " + "others later.\n\n(make choice; q to abort. If unsure, start from 1.)") options = [] options.append( {"desc": "|WPrototype-Key|n|n{}".format(_format_option_value("Key", True, prototype, None)), "goto": "node_prototype_key"}) - for key in ('Prototype', 'Typeclass', 'Key', 'Aliases', 'Attrs', 'Tags', 'Locks', + for key in ('Typeclass', 'Prototype-parent', 'Key', 'Aliases', 'Attrs', 'Tags', 'Locks', 'Permissions', 'Location', 'Home', 'Destination'): required = False cropper = None - if key in ("Prototype", "Typeclass"): + if key in ("Prototype-parent", "Typeclass"): required = "prototype" not in prototype and "typeclass" not in prototype if key == 'Typeclass': cropper = _path_cropper @@ -215,6 +223,12 @@ def node_index(caller): {"desc": "|WPrototype-{}|n|n{}".format( key, _format_option_value(key, required, prototype, None)), "goto": "node_prototype_{}".format(key.lower())}) + for key in ("Load", "Save", "Spawn"): + options.append( + {"key": ("|w{}|W{}".format(key[0], key[1:]), key[0]), + "desc": "|W{}|n".format( + key, _format_option_value(key, required, prototype, None)), + "goto": "node_prototype_{}".format(key.lower())}) return text, options @@ -429,54 +443,82 @@ def _caller_attrs(caller): return attrs -def _attrparse(caller, attr_string): - "attr is entering on the form 'attr = value'" +def _display_attribute(attr_tuple): + """Pretty-print attribute tuple""" + attrkey, value, category, locks, default_access = attr_tuple + value = protlib.protfunc_parser(value) + typ = type(value) + out = ("Attribute key: '{attrkey}' (category: {category}, " + "locks: {locks})\n" + "Value (parsed to {typ}): {value}").format( + attrkey=attrkey, + category=category, locks=locks, + typ=typ, value=value) + return out + + +def _add_attr(caller, attr_string, **kwargs): + """ + Add new attrubute, parsing input. + attr is entered on these forms + attr = value + attr;category = value + attr;category;lockstring = value + + """ + attrname = '' + category = None + locks = '' if '=' in attr_string: attrname, value = (part.strip() for part in attr_string.split('=', 1)) attrname = attrname.lower() - if attrname: - try: - value = literal_eval(value) - except SyntaxError: - caller.msg(_MENU_ATTR_LITERAL_EVAL_ERROR) - else: - return attrname, value - else: - return None, None + nameparts = attrname.split(";", 2) + nparts = len(nameparts) + if nparts == 2: + attrname, category = nameparts + elif nparts > 2: + attrname, category, locks = nameparts + attr_tuple = (attrname, category, locks) - -def _add_attr(caller, attr_string, **kwargs): - attrname, value = _attrparse(caller, attr_string) if attrname: prot = _get_menu_prototype(caller) - prot['attrs'][attrname] = value - _set_prototype_value(caller, "prototype", prot) - text = "Added" + attrs = prot.get('attrs', []) + + try: + # replace existing attribute with the same name in the prototype + ind = [tup[0] for tup in attrs].index(attrname) + attrs[ind] = attr_tuple + except IndexError: + attrs.append(attr_tuple) + + _set_prototype_value(caller, "attrs", attrs) + + text = kwargs.get('text') + if not text: + if 'edit' in kwargs: + text = "Edited " + _display_attribute(attr_tuple) + else: + text = "Added " + _display_attribute(attr_tuple) else: - text = "Attribute must be given as 'attrname = ' where uses valid Python." + text = "Attribute must be given as 'attrname[;category;locks] = '." + options = {"key": "_default", "goto": lambda caller: None} return text, options def _edit_attr(caller, attrname, new_value, **kwargs): - attrname, value = _attrparse("{}={}".format(caller, attrname, new_value)) - if attrname: - prot = _get_menu_prototype(caller) - prot['attrs'][attrname] = value - text = "Edited Attribute {} = {}".format(attrname, value) - else: - text = "Attribute value must be valid Python." - options = {"key": "_default", - "goto": lambda caller: None} - return text, options + + attr_string = "{}={}".format(attrname, new_value) + + return _add_attr(caller, attr_string, edit=True) def _examine_attr(caller, selection): prot = _get_menu_prototype(caller) - value = prot['attrs'][selection] - return "Attribute {} = {}".format(selection, value) + attr_tuple = prot['attrs'][selection] + return _display_attribute(attr_tuple) @list_node(_caller_attrs) @@ -484,8 +526,12 @@ def node_attrs(caller): prot = _get_menu_prototype(caller) attrs = prot.get("attrs") - text = ["Set the prototype's |yAttributes|n. Separate multiple attrs with commas. " - "Will retain case sensitivity."] + text = ["Set the prototype's |yAttributes|n. Enter attributes on one of these forms:\n" + " attrname=value\n attrname;category=value\n attrname;category;lockstring=value\n" + "To give an attribute without a category but with a lockstring, leave that spot empty " + "(attrname;;lockstring=value)." + "Separate multiple attrs with commas. Use quotes to escape inputs with commas and " + "semi-colon."] if attrs: text.append("Current attrs are '|y{attrs}|n'.".format(attrs=attrs)) else: @@ -506,46 +552,78 @@ def _caller_tags(caller): return tags +def _display_tag(tag_tuple): + """Pretty-print attribute tuple""" + tagkey, category, data = tag_tuple + out = ("Tag: '{tagkey}' (category: {category}{})".format( + tagkey=tagkey, category=category, data=", data: {}".format(data) if data else "")) + return out + + def _add_tag(caller, tag, **kwargs): + """ + Add tags to the system, parsing this syntax: + tagname + tagname;category + tagname;category;data + + """ + tag = tag.strip().lower() - prototype = _get_menu_prototype(caller) - tags = prototype.get('tags', []) - if tags: - if tag not in tags: - tags.append(tag) + category = None + data = "" + + tagtuple = tag.split(";", 2) + ntuple = len(tagtuple) + + if ntuple == 2: + tag, category = tagtuple + elif ntuple > 2: + tag, category, data = tagtuple + + tag_tuple = (tag, category, data) + + if tag: + prot = _get_menu_prototype(caller) + tags = prot.get('tags', []) + + old_tag = kwargs.get("edit", None) + + if old_tag: + # editing a tag means removing the old and replacing with new + try: + ind = [tup[0] for tup in tags].index(old_tag) + del tags[ind] + except IndexError: + pass + + tags.append(tag_tuple) + + _set_prototype_value(caller, "tags", tags) + + text = kwargs.get('text') + if not text: + if 'edit' in kwargs: + text = "Edited " + _display_tag(tag_tuple) + else: + text = "Added " + _display_tag(tag_tuple) else: - tags = [tag] - prototype['tags'] = tags - _set_prototype_value(caller, "prototype", prototype) - text = kwargs.get("text") - if not text: - text = "Added tag {}. (return to continue)".format(tag) + text = "Tag must be given as 'tag[;category;data]." + options = {"key": "_default", "goto": lambda caller: None} return text, options def _edit_tag(caller, old_tag, new_tag, **kwargs): - prototype = _get_menu_prototype(caller) - tags = prototype.get('tags', []) - - old_tag = old_tag.strip().lower() - new_tag = new_tag.strip().lower() - tags[tags.index(old_tag)] = new_tag - prototype['tags'] = tags - _set_prototype_value(caller, 'prototype', prototype) - - text = kwargs.get('text') - if not text: - text = "Changed tag {} to {}.".format(old_tag, new_tag) - options = {"key": "_default", - "goto": lambda caller: None} - return text, options + return _add_tag(caller, new_tag, edit=old_tag) @list_node(_caller_tags) def node_tags(caller): - text = "Set the prototype's |yTags|n." + text = ("Set the prototype's |yTags|n. Enter tags on one of the following forms:\n" + " tag\n tag;category\n tag;category;data\n" + "Note that 'data' is not commonly used.") options = _wizard_options("tags", "attrs", "locks") return text, options @@ -650,7 +728,7 @@ def node_destination(caller): def node_prototype_desc(caller): prototype = _get_menu_prototype(caller) - text = ["The |wMeta-Description|n briefly describes the prototype for viewing in listings."] + text = ["The |wPrototype-Description|n briefly describes the prototype for viewing in listings."] desc = prototype.get("prototype_desc", None) if desc: @@ -670,7 +748,7 @@ def node_prototype_desc(caller): def node_prototype_tags(caller): prototype = _get_menu_prototype(caller) - text = ["|wMeta-Tags|n can be used to classify and find prototypes. Tags are case-insensitive. " + text = ["|wPrototype-Tags|n can be used to classify and find prototypes. Tags are case-insensitive. " "Separate multiple by tags by commas."] tags = prototype.get('prototype_tags', []) @@ -691,15 +769,15 @@ def node_prototype_tags(caller): def node_prototype_locks(caller): prototype = _get_menu_prototype(caller) - text = ["Set |wMeta-Locks|n on the prototype. There are two valid lock types: " - "'edit' (who can edit the prototype) and 'use' (who can apply the prototype)\n" - "(If you are unsure, leave as default.)"] + text = ["Set |wPrototype-Locks|n on the prototype. There are two valid lock types: " + "'edit' (who can edit the prototype) and 'spawn' (who can spawn new objects with this " + "prototype)\n(If you are unsure, leave as default.)"] locks = prototype.get('prototype_locks', '') if locks: text.append("Current lock is |w'{lockstring}'|n".format(lockstring=locks)) else: text.append("Lock unset - if not changed the default lockstring will be set as\n" - " |w'use:all(); edit:id({dbref}) or perm(Admin)'|n".format(dbref=caller.id)) + " |w'spawn:all(); edit:id({dbref}) or perm(Admin)'|n".format(dbref=caller.id)) text = "\n\n".join(text) options = _wizard_options("prototype_locks", "prototype_tags", "index") options.append({"key": "_default", @@ -710,6 +788,21 @@ def node_prototype_locks(caller): return text, options +def node_prototype_load(caller): + # load prototype from storage + pass + + +def node_prototype_save(caller): + # save current prototype to disk + pass + + +def node_prototype_spawn(caller): + # spawn an instance of this prototype + pass + + class OLCMenu(EvMenu): """ A custom EvMenu with a different formatting for the options. @@ -766,5 +859,8 @@ def start_olc(caller, session=None, prototype=None): "node_prototype_desc": node_prototype_desc, "node_prototype_tags": node_prototype_tags, "node_prototype_locks": node_prototype_locks, + "node_prototype_load": node_prototype_load, + "node_prototype_save": node_prototype_save, + "node_prototype_spawn": node_prototype_spawn } OLCMenu(caller, menudata, startnode='node_index', session=session, olc_prototype=prototype) diff --git a/evennia/prototypes/prototypes.py b/evennia/prototypes/prototypes.py index 2ab3416afe..6f155fdac9 100644 --- a/evennia/prototypes/prototypes.py +++ b/evennia/prototypes/prototypes.py @@ -22,7 +22,11 @@ from evennia.utils.evtable import EvTable _MODULE_PROTOTYPE_MODULES = {} _MODULE_PROTOTYPES = {} -_PROTOTYPE_META_NAMES = ("prototype_key", "prototype_desc", "prototype_tags", "prototype_locks") +_PROTOTYPE_META_NAMES = ( + "prototype_key", "prototype_desc", "prototype_tags", "prototype_locks", "prototype_parent") +_PROTOTYPE_RESERVED_KEYS = _PROTOTYPE_META_NAMES + ( + "key", "aliases", "typeclass", "location", "home", "destination", + "permissions", "locks", "exec", "tags", "attrs") _PROTOTYPE_TAG_CATEGORY = "from_prototype" _PROTOTYPE_TAG_META_CATEGORY = "db_prototype" _PROT_FUNCS = {} diff --git a/evennia/prototypes/spawner.py b/evennia/prototypes/spawner.py index df07e3b155..71aecfd61e 100644 --- a/evennia/prototypes/spawner.py +++ b/evennia/prototypes/spawner.py @@ -31,10 +31,10 @@ Possible keywords are: supported are 'edit' and 'use'. prototype_tags(list, optional): List of tags or tuples (tag, category) used to group prototype in listings - prototype_parent (str, tuple or callable, optional): name (prototype_key) of eventual parent prototype, or a list of parents, for multiple left-to-right inheritance. prototype: Deprecated. Same meaning as 'parent'. + typeclass (str or callable, optional): if not set, will use typeclass of parent prototype or use `settings.BASE_OBJECT_TYPECLASS` key (str or callable, optional): the name of the spawned object. If not given this will set to a From df866fb3f3f8c8efedb2290118dea2094f2f7fa3 Mon Sep 17 00:00:00 2001 From: Tehom Date: Thu, 28 Jun 2018 08:23:52 -0400 Subject: [PATCH 156/208] Add annotate method to TypedManager to filter by typeclass appropriately. --- evennia/typeclasses/managers.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/evennia/typeclasses/managers.py b/evennia/typeclasses/managers.py index e6ef778eb8..d6a08de16d 100644 --- a/evennia/typeclasses/managers.py +++ b/evennia/typeclasses/managers.py @@ -619,6 +619,18 @@ class TypeclassManager(TypedObjectManager): """ return super(TypeclassManager, self).filter(db_typeclass_path=self.model.path).count() + def annotate(self, *args, **kwargs): + """ + Overload annotate method to first call .all() to filter on typeclass before annotating. + Args: + *args (any): Positional arguments passed along to queryset annotate method. + **kwargs (any): Keyword arguments passed along to queryset annotate method. + + Returns: + Annotated queryset. + """ + return super(TypeclassManager, self).filter(db_typeclass_path=self.model.path).annotate(*args, **kwargs) + def _get_subclasses(self, cls): """ Recursively get all subclasses to a class. From 2cf8f3a97b0f371629940d2189796aa1c1738202 Mon Sep 17 00:00:00 2001 From: Tehom Date: Sat, 30 Jun 2018 02:31:47 -0400 Subject: [PATCH 157/208] Add on missing values and values_list methods while we're at it, for the same reasons. --- evennia/typeclasses/managers.py | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/evennia/typeclasses/managers.py b/evennia/typeclasses/managers.py index d6a08de16d..be9a6dd2c4 100644 --- a/evennia/typeclasses/managers.py +++ b/evennia/typeclasses/managers.py @@ -621,7 +621,7 @@ class TypeclassManager(TypedObjectManager): def annotate(self, *args, **kwargs): """ - Overload annotate method to first call .all() to filter on typeclass before annotating. + Overload annotate method to filter on typeclass before annotating. Args: *args (any): Positional arguments passed along to queryset annotate method. **kwargs (any): Keyword arguments passed along to queryset annotate method. @@ -631,6 +631,30 @@ class TypeclassManager(TypedObjectManager): """ return super(TypeclassManager, self).filter(db_typeclass_path=self.model.path).annotate(*args, **kwargs) + def values(self, *args, **kwargs): + """ + Overload values method to filter on typeclass first. + Args: + *args (any): Positional arguments passed along to values method. + **kwargs (any): Keyword arguments passed along to values method. + + Returns: + Queryset of values dictionaries, just filtered by typeclass first. + """ + return super(TypeclassManager, self).filter(db_typeclass_path=self.model.path).values(*args, **kwargs) + + def values_list(self, *args, **kwargs): + """ + Overload values method to filter on typeclass first. + Args: + *args (any): Positional arguments passed along to values_list method. + **kwargs (any): Keyword arguments passed along to values_list method. + + Returns: + Queryset of value_list tuples, just filtered by typeclass first. + """ + return super(TypeclassManager, self).filter(db_typeclass_path=self.model.path).values_list(*args, **kwargs) + def _get_subclasses(self, cls): """ Recursively get all subclasses to a class. From 55ad8adb73307a232c2538f818795fc29768050d Mon Sep 17 00:00:00 2001 From: Griatch Date: Sat, 30 Jun 2018 16:37:30 +0200 Subject: [PATCH 158/208] Complete design of olc menu, not tested yet --- evennia/prototypes/menus.py | 207 ++++++++++++++++++++++++++----- evennia/prototypes/prototypes.py | 4 +- 2 files changed, 180 insertions(+), 31 deletions(-) diff --git a/evennia/prototypes/menus.py b/evennia/prototypes/menus.py index 80e34e4c21..faeae88fca 100644 --- a/evennia/prototypes/menus.py +++ b/evennia/prototypes/menus.py @@ -5,7 +5,6 @@ OLC Prototype menu nodes """ import json -from ast import literal_eval from django.conf import settings from evennia.utils.evmenu import EvMenu, list_node from evennia.utils.ansi import strip_ansi @@ -40,6 +39,13 @@ def _get_menu_prototype(caller): return prototype +def _set_menu_prototype(caller, prototype): + """Set the prototype with existing one""" + caller.ndb._menutree.olc_prototype = prototype + caller.ndb._menutree.olc_new = False + return prototype + + def _is_new_prototype(caller): """Check if prototype is marked as new or was loaded from a saved one.""" return hasattr(caller.ndb._menutree, "olc_new") @@ -177,7 +183,7 @@ def _wizard_options(curr_node, prev_node, next_node, color="|W"): if curr_node: options.append({"key": ("|wv|Walidate prototype", "v"), - "goto": ("node_validate_prototype", {"back": curr_node})}) + "goto": ("node_view_prototype", {"back": curr_node})}) return options @@ -187,6 +193,26 @@ def _path_cropper(pythonpath): return pythonpath.split('.')[-1] +def _validate_prototype(prototype): + """Run validation on prototype""" + + txt = protlib.prototype_to_str(prototype) + errors = "\n\n|g No validation errors found.|n (but errors could still happen at spawn-time)" + err = False + try: + # validate, don't spawn + spawner.spawn(prototype, only_validate=True) + except RuntimeError as err: + errors = "\n\n|r{}|n".format(err) + err = True + except RuntimeWarning as err: + errors = "\n\n|y{}|n".format(err) + err = True + + text = (txt + errors) + return err, text + + # Menu nodes def node_index(caller): @@ -223,7 +249,7 @@ def node_index(caller): {"desc": "|WPrototype-{}|n|n{}".format( key, _format_option_value(key, required, prototype, None)), "goto": "node_prototype_{}".format(key.lower())}) - for key in ("Load", "Save", "Spawn"): + for key in ("Save", "Spawn", "Load"): options.append( {"key": ("|w{}|W{}".format(key[0], key[1:]), key[0]), "desc": "|W{}|n".format( @@ -233,22 +259,18 @@ def node_index(caller): return text, options -def node_validate_prototype(caller, raw_string, **kwargs): - prototype = _get_menu_prototype(caller) +def node_view_prototype(caller, raw_string, **kwargs): + """General node to view and validate a protototype""" + prototype = kwargs.get('prototype', _get_menu_prototype(caller)) + validate = kwargs.get("validate", True) + prev_node = kwargs.get("back", "node_index") - txt = protlib.prototype_to_str(prototype) - errors = "\n\n|g No validation errors found.|n (but errors could still happen at spawn-time)" - try: - # validate, don't spawn - spawner.spawn(prototype, only_validate=True) - except RuntimeError as err: - errors = "\n\n|r{}|n".format(err) - except RuntimeWarning as err: - errors = "\n\n|y{}|n".format(err) + if validate: + _, text = _validate_prototype(prototype) + else: + text = protlib.prototype_to_str(prototype) - text = (txt + errors) - - options = _wizard_options(None, kwargs.get("back"), None) + options = _wizard_options(None, prev_node, None) return text, options @@ -728,7 +750,8 @@ def node_destination(caller): def node_prototype_desc(caller): prototype = _get_menu_prototype(caller) - text = ["The |wPrototype-Description|n briefly describes the prototype for viewing in listings."] + text = ["The |wPrototype-Description|n briefly describes the prototype for " + "viewing in listings."] desc = prototype.get("prototype_desc", None) if desc: @@ -748,7 +771,8 @@ def node_prototype_desc(caller): def node_prototype_tags(caller): prototype = _get_menu_prototype(caller) - text = ["|wPrototype-Tags|n can be used to classify and find prototypes. Tags are case-insensitive. " + text = ["|wPrototype-Tags|n can be used to classify and find prototypes. " + "Tags are case-insensitive. " "Separate multiple by tags by commas."] tags = prototype.get('prototype_tags', []) @@ -788,19 +812,144 @@ def node_prototype_locks(caller): return text, options -def node_prototype_load(caller): - # load prototype from storage - pass +def node_prototype_save(caller, **kwargs): + """Save prototype to disk """ + # these are only set if we selected 'yes' to save on a previous pass + accept_save = kwargs.get("accept", False) + prototype = kwargs.get("prototype", None) + + if accept_save and prototype: + # we already validated and accepted the save, so this node acts as a goto callback and + # should now only return the next node + protlib.save_prototype(**prototype) + caller.msg("|gPrototype saved.|n") + return "node_spawn" + + # not validated yet + prototype = _get_menu_prototype(caller) + error, text = _validate_prototype(prototype) + + text = [text] + + if error: + # abort save + text.append( + "Validation errors were found. They need to be corrected before this prototype " + "can be saved (or used to spawn).") + options = _wizard_options("prototype_save", "prototype_locks", "index") + return "\n".join(text), options + + prototype_key = prototype['prototype_key'] + if protlib.search_prototype(prototype_key): + text.append("Do you want to save/overwrite the existing prototype '{name}'?".format( + name=prototype_key)) + else: + text.append("Do you want to save the prototype as '{name}'?".format(prototype_key)) + + options = ( + {"key": ("[|wY|Wes|n]", "yes", "y"), + "goto": lambda caller: + node_prototype_save(caller, + {"accept": True, "prototype": prototype})}, + {"key": ("|wN|Wo|n", "n"), + "goto": "node_spawn"}, + {"key": "_default", + "goto": lambda caller: + node_prototype_save(caller, + {"accept": True, "prototype": prototype})}) + + return "\n".join(text), options -def node_prototype_save(caller): - # save current prototype to disk - pass +def _spawn(caller, **kwargs): + """Spawn prototype""" + prototype = kwargs["prototype"].copy() + new_location = kwargs.get('location', None) + if new_location: + prototype['location'] = new_location + obj = spawner.spawn(prototype) + if obj: + caller.msg("|gNew instance|n {key} ({dbref}) |gspawned.|n".format( + key=obj.key, dbref=obj.dbref)) + else: + caller.msg("|rError: Spawner did not return a new instance.|n") -def node_prototype_spawn(caller): - # spawn an instance of this prototype - pass +def _update_spawned(caller, **kwargs): + """update existing objects""" + prototype = kwargs['prototype'] + objects = kwargs['objects'] + num_changed = spawner.batch_update_objects_with_prototype(prototype, objects=objects) + caller.msg("|g{num} objects were updated successfully.|n".format(num=num_changed)) + + +def node_prototype_spawn(caller, **kwargs): + """Submenu for spawning the prototype""" + + prototype = _get_menu_prototype(caller) + error, text = _validate_prototype(prototype) + + text = [text] + + if error: + text.append("|rPrototype validation failed. Correct the errors before spawning.|n") + options = _wizard_options("prototype_spawn", "prototype_locks", "index") + return "\n".join(text), options + + # show spawn submenu options + options = [] + prototype_key = prototype['prototype_key'] + location = prototype.get('location', None) + + if location: + options.append( + {"desc": "Spawn in prototype's defined location ({loc})".format(loc=location), + "goto": (_spawn, + dict(prototype=prototype))}) + caller_loc = caller.location + if location != caller_loc: + options.append( + {"desc": "Spawn in {caller}'s location ({loc})".format( + caller=caller, loc=caller_loc), + "goto": (_spawn, + dict(prototype=prototype, location=caller_loc))}) + if location != caller_loc != caller: + options.append( + {"desc": "Spawn in {caller}'s inventory".format(caller=caller), + "goto": (_spawn, + dict(prototype=prototype, location=caller))}) + + spawned_objects = protlib.search_objects_with_prototype(prototype_key) + nspawned = spawned_objects.count() + if spawned_objects: + options.append( + {"desc": "Update {num} existing objects with this prototype".format(num=nspawned), + "goto": (_update_spawned, + dict(prototype=prototype, + opjects=spawned_objects))}) + options.extend(_wizard_options("prototype_spawn", "prototype_save", "index")) + return text, options + + +def _prototype_load_select(caller, prototype_key): + matches = protlib.search_prototype(key=prototype_key) + if matches: + prototype = matches[0] + _set_menu_prototype(caller, prototype) + caller.msg("|gLoaded prototype '{}'.".format(prototype_key)) + return "node_index" + else: + caller.msg("|rFailed to load prototype '{}'.".format(prototype_key)) + return None + + +@list_node(_all_prototype_parents, _prototype_load_select) +def node_prototype_load(caller, **kwargs): + text = ["Select a prototype to load. This will replace any currently edited prototype."] + options = _wizard_options("load", "save", "index") + options.append({"key": "_default", + "goto": _prototype_parent_examine}) + return "\n".join(text), options class OLCMenu(EvMenu): @@ -843,7 +992,7 @@ def start_olc(caller, session=None, prototype=None): """ menudata = {"node_index": node_index, - "node_validate_prototype": node_validate_prototype, + "node_view_prototype": node_view_prototype, "node_prototype_key": node_prototype_key, "node_prototype_parent": node_prototype_parent, "node_typeclass": node_typeclass, diff --git a/evennia/prototypes/prototypes.py b/evennia/prototypes/prototypes.py index 6f155fdac9..c02041d8bc 100644 --- a/evennia/prototypes/prototypes.py +++ b/evennia/prototypes/prototypes.py @@ -258,7 +258,7 @@ class DbPrototype(DefaultScript): # Prototype manager functions -def create_prototype(**kwargs): +def save_prototype(**kwargs): """ Create/Store a prototype persistently. @@ -335,7 +335,7 @@ def create_prototype(**kwargs): return stored_prototype.db.prototype # alias -save_prototype = create_prototype +create_prototype = save_prototype def delete_prototype(key, caller=None): From 644b6906adebecaf0dc4ef82f3f82642a49ab17d Mon Sep 17 00:00:00 2001 From: Griatch Date: Sun, 1 Jul 2018 00:17:43 +0200 Subject: [PATCH 159/208] Start debugging olc menu structure --- evennia/prototypes/menus.py | 67 +++++++++++++++++++------------------ evennia/prototypes/tests.py | 2 ++ 2 files changed, 37 insertions(+), 32 deletions(-) diff --git a/evennia/prototypes/menus.py b/evennia/prototypes/menus.py index faeae88fca..f978aae566 100644 --- a/evennia/prototypes/menus.py +++ b/evennia/prototypes/menus.py @@ -167,23 +167,23 @@ def _wizard_options(curr_node, prev_node, next_node, color="|W"): """Creates default navigation options available in the wizard.""" options = [] if prev_node: - options.append({"key": ("|wb|Wack", "b"), + options.append({"key": ("|wB|Wack", "b"), "desc": "{color}({node})|n".format( color=color, node=prev_node.replace("_", "-")), "goto": "node_{}".format(prev_node)}) if next_node: - options.append({"key": ("|wf|Worward", "f"), + options.append({"key": ("|wF|Worward", "f"), "desc": "{color}({node})|n".format( color=color, node=next_node.replace("_", "-")), "goto": "node_{}".format(next_node)}) if "index" not in (prev_node, next_node): - options.append({"key": ("|wi|Wndex", "i"), + options.append({"key": ("|wI|Wndex", "i"), "goto": "node_index"}) if curr_node: - options.append({"key": ("|wv|Walidate prototype", "v"), - "goto": ("node_view_prototype", {"back": curr_node})}) + options.append({"key": ("|wV|Walidate prototype", "validate", "v"), + "goto": ("node_validate_prototype", {"back": curr_node})}) return options @@ -229,19 +229,21 @@ def node_index(caller): options = [] options.append( - {"desc": "|WPrototype-Key|n|n{}".format(_format_option_value("Key", True, prototype, None)), + {"desc": "|WPrototype-Key|n|n{}".format( + _format_option_value("Key", "prototype_key" not in prototype, prototype, None)), "goto": "node_prototype_key"}) - for key in ('Typeclass', 'Prototype-parent', 'Key', 'Aliases', 'Attrs', 'Tags', 'Locks', + for key in ('Typeclass', 'Prototype_parent', 'Key', 'Aliases', 'Attrs', 'Tags', 'Locks', 'Permissions', 'Location', 'Home', 'Destination'): required = False cropper = None if key in ("Prototype-parent", "Typeclass"): - required = "prototype" not in prototype and "typeclass" not in prototype + required = "prototype_parent" not in prototype and "typeclass" not in prototype if key == 'Typeclass': cropper = _path_cropper options.append( {"desc": "|w{}|n{}".format( - key, _format_option_value(key, required, prototype, cropper=cropper)), + key.replace("_", "-"), + _format_option_value(key, required, prototype, cropper=cropper)), "goto": "node_{}".format(key.lower())}) required = False for key in ('Desc', 'Tags', 'Locks'): @@ -249,26 +251,26 @@ def node_index(caller): {"desc": "|WPrototype-{}|n|n{}".format( key, _format_option_value(key, required, prototype, None)), "goto": "node_prototype_{}".format(key.lower())}) - for key in ("Save", "Spawn", "Load"): - options.append( - {"key": ("|w{}|W{}".format(key[0], key[1:]), key[0]), - "desc": "|W{}|n".format( - key, _format_option_value(key, required, prototype, None)), - "goto": "node_prototype_{}".format(key.lower())}) + + options.extend(( + {"key": ("|wV|Walidate prototype", "validate", "v"), + "goto": "node_validate_prototype"}, + {"key": ("|wS|Wave prototype", "save", "s"), + "goto": "node_prototype_save"}, + {"key": ("|wSP|Wawn prototype", "spawn", "sp"), + "goto": "node_prototype_spawn"}, + {"key": ("|wL|Woad prototype", "load", "l"), + "goto": "node_prototype_load"})) return text, options -def node_view_prototype(caller, raw_string, **kwargs): +def node_validate_prototype(caller, raw_string, **kwargs): """General node to view and validate a protototype""" - prototype = kwargs.get('prototype', _get_menu_prototype(caller)) - validate = kwargs.get("validate", True) - prev_node = kwargs.get("back", "node_index") + prototype = _get_menu_prototype(caller) + prev_node = kwargs.get("back", "index") - if validate: - _, text = _validate_prototype(prototype) - else: - text = protlib.prototype_to_str(prototype) + _, text = _validate_prototype(prototype) options = _wizard_options(None, prev_node, None) @@ -310,7 +312,7 @@ def node_prototype_key(caller): text.append("The key is currently unset.") text.append("Enter text or make a choice (q for quit)") text = "\n\n".join(text) - options = _wizard_options("prototype_key", "index", "prototype") + options = _wizard_options("prototype_key", "index", "prototype_parent") options.append({"key": "_default", "goto": _check_prototype_key}) return text, options @@ -334,7 +336,7 @@ def _prototype_parent_examine(caller, prototype_name): def _prototype_parent_select(caller, prototype): - ret = _set_property(caller, prototype['prototype_key'], + ret = _set_property(caller, "", prop="prototype_parent", processor=str, next_node="node_key") caller.msg("Selected prototype |y{}|n. Removed any set typeclass parent.".format(prototype)) return ret @@ -358,7 +360,7 @@ def node_prototype_parent(caller): else: text.append("Parent prototype is not set") text = "\n\n".join(text) - options = _wizard_options("prototype", "prototype_key", "typeclass", color="|W") + options = _wizard_options("prototype_parent", "prototype_key", "typeclass", color="|W") options.append({"key": "_default", "goto": _prototype_parent_examine}) @@ -414,7 +416,7 @@ def node_typeclass(caller): text.append("Using default typeclass {typeclass}.".format( typeclass=settings.BASE_OBJECT_TYPECLASS)) text = "\n\n".join(text) - options = _wizard_options("typeclass", "prototype", "key", color="|W") + options = _wizard_options("typeclass", "prototype_parent", "key", color="|W") options.append({"key": "_default", "goto": _typeclass_examine}) return text, options @@ -923,7 +925,7 @@ def node_prototype_spawn(caller, **kwargs): nspawned = spawned_objects.count() if spawned_objects: options.append( - {"desc": "Update {num} existing objects with this prototype".format(num=nspawned), + {"desc": "Update {num} existing objects with this prototype".format(num=nspawned), "goto": (_update_spawned, dict(prototype=prototype, opjects=spawned_objects))}) @@ -962,18 +964,19 @@ class OLCMenu(EvMenu): Split the options into two blocks - olc options and normal options """ - olc_keys = ("index", "forward", "back", "previous", "next", "validate prototype") + olc_keys = ("index", "forward", "back", "previous", "next", "validate prototype", + "save prototype", "load prototype", "spawn prototype") olc_options = [] other_options = [] for key, desc in optionlist: - raw_key = strip_ansi(key) + raw_key = strip_ansi(key).lower() if raw_key in olc_keys: desc = " {}".format(desc) if desc else "" olc_options.append("|lc{}|lt{}|le{}".format(raw_key, key, desc)) else: other_options.append((key, desc)) - olc_options = " | ".join(olc_options) + " | " + "|wq|Wuit" if olc_options else "" + olc_options = " | ".join(olc_options) + " | " + "|wQ|Wuit" if olc_options else "" other_options = super(OLCMenu, self).options_formatter(other_options) sep = "\n\n" if olc_options and other_options else "" @@ -992,7 +995,7 @@ def start_olc(caller, session=None, prototype=None): """ menudata = {"node_index": node_index, - "node_view_prototype": node_view_prototype, + "node_validate_prototype": node_validate_prototype, "node_prototype_key": node_prototype_key, "node_prototype_parent": node_prototype_parent, "node_typeclass": node_typeclass, diff --git a/evennia/prototypes/tests.py b/evennia/prototypes/tests.py index 49624905c7..0d5e247378 100644 --- a/evennia/prototypes/tests.py +++ b/evennia/prototypes/tests.py @@ -355,6 +355,8 @@ class TestMenuModule(EvenniaTest): {'goto': ('node_validate_prototype', {'back': 'ThisNode'}), 'key': ('|wv|Walidate prototype', 'v')}]) + self.assertEqual(olc_menus._validate_prototype(self.test_prot, (False, Something))) + def test_node_helpers(self): caller = self.caller From 649cb44ba1826059c61b8292542856ae3c8c9786 Mon Sep 17 00:00:00 2001 From: Griatch Date: Tue, 3 Jul 2018 23:56:55 +0200 Subject: [PATCH 160/208] Add functionality for object-update menu node, untested --- CHANGELOG.md | 40 ++++++-- evennia/prototypes/menus.py | 166 +++++++++++++++++++++++++------ evennia/prototypes/prototypes.py | 20 ++-- evennia/prototypes/spawner.py | 18 +++- evennia/prototypes/tests.py | 85 +++++++++++++--- 5 files changed, 265 insertions(+), 64 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a05d65fdc0..3c1c4cf787 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,29 @@ -# Evennia Changelog +# Changelog -# Sept 2017: -Release of Evennia 0.7; upgrade to Django 1.11, change 'Player' to +## Evennia 0.8 (2018) + +### Prototype changes + +- A new form of prototype - database-stored prototypes, editable from in-game. The old, + module-created prototypes remain as read-only prototypes. +- All prototypes must have a key `prototype_key` identifying the prototype in listings. This is + checked to be server-unique. Prototypes created in a module will use the global variable name they + are assigned to if no `prototype_key` is given. +- Prototype field `prototype` was renamed to `prototype_parent` to avoid mixing terms. +- All prototypes must either have `typeclass` or `prototype_parent` defined. If using + `prototype_parent`, `typeclass` must be defined somewhere in the inheritance chain. This is a + change from Evennia 0.7 which allowed 'mixin' prototypes without `typeclass`/`prototype_key`. To + make a mixin now, give it a default typeclass, like `evennia.objects.objects.DefaultObject` and just + override in the child as needed. +- The spawn command was extended to accept a full prototype on one line. +- The spawn command got the /save switch to save the defined prototype and its key. +- The command spawn/menu will now start an OLC (OnLine Creation) menu to load/save/edit/spawn prototypes. + + +# Overviews + +## Sept 2017: +Release of Evennia 0.7; upgrade to Django 1.11, change 'Player' to 'Account', rework the website template and a slew of other updates. Info on what changed and how to migrate is found here: https://groups.google.com/forum/#!msg/evennia/0JYYNGY-NfE/cDFaIwmPBAAJ @@ -14,9 +36,9 @@ Lots of bugfixes and considerable uptick in contributors. Unittest coverage and PEP8 adoption and refactoring. ## May 2016: -Evennia 0.6 with completely reworked Out-of-band system, making +Evennia 0.6 with completely reworked Out-of-band system, making the message path completely flexible and built around input/outputfuncs. -A completely new webclient, split into the evennia.js library and a +A completely new webclient, split into the evennia.js library and a gui library, making it easier to customize. ## Feb 2016: @@ -33,15 +55,15 @@ library format with a stand-alone launcher, in preparation for making an 'evennia' pypy package and using versioning. The version we will merge with will likely be 0.5. There is also work with an expanded testing structure and the use of threading for saves. We also now -use Travis for automatic build checking. +use Travis for automatic build checking. ## Sept 2014: Updated to Django 1.7+ which means South dependency was dropped and minimum Python version upped to 2.7. MULTISESSION_MODE=3 was added -and the web customization system was overhauled using the latest -functionality of django. Otherwise, mostly bug-fixes and +and the web customization system was overhauled using the latest +functionality of django. Otherwise, mostly bug-fixes and implementation of various smaller feature requests as we got used -to github. Many new users have appeared. +to github. Many new users have appeared. ## Jan 2014: Moved Evennia project from Google Code to github.com/evennia/evennia. diff --git a/evennia/prototypes/menus.py b/evennia/prototypes/menus.py index f978aae566..af670b743a 100644 --- a/evennia/prototypes/menus.py +++ b/evennia/prototypes/menus.py @@ -5,6 +5,7 @@ OLC Prototype menu nodes """ import json +from random import choice from django.conf import settings from evennia.utils.evmenu import EvMenu, list_node from evennia.utils.ansi import strip_ansi @@ -469,7 +470,7 @@ def _caller_attrs(caller): def _display_attribute(attr_tuple): """Pretty-print attribute tuple""" - attrkey, value, category, locks, default_access = attr_tuple + attrkey, value, category, locks = attr_tuple value = protlib.protfunc_parser(value) typ = type(value) out = ("Attribute key: '{attrkey}' (category: {category}, " @@ -503,7 +504,7 @@ def _add_attr(caller, attr_string, **kwargs): attrname, category = nameparts elif nparts > 2: attrname, category, locks = nameparts - attr_tuple = (attrname, category, locks) + attr_tuple = (attrname, value, category, locks) if attrname: prot = _get_menu_prototype(caller) @@ -513,7 +514,7 @@ def _add_attr(caller, attr_string, **kwargs): # replace existing attribute with the same name in the prototype ind = [tup[0] for tup in attrs].index(attrname) attrs[ind] = attr_tuple - except IndexError: + except ValueError: attrs.append(attr_tuple) _set_prototype_value(caller, "attrs", attrs) @@ -541,7 +542,8 @@ def _edit_attr(caller, attrname, new_value, **kwargs): def _examine_attr(caller, selection): prot = _get_menu_prototype(caller) - attr_tuple = prot['attrs'][selection] + ind = [part[0] for part in prot['attrs']].index(selection) + attr_tuple = prot['attrs'][ind] return _display_attribute(attr_tuple) @@ -572,15 +574,15 @@ def node_attrs(caller): def _caller_tags(caller): prototype = _get_menu_prototype(caller) - tags = prototype.get("tags") + tags = prototype.get("tags", []) return tags def _display_tag(tag_tuple): """Pretty-print attribute tuple""" tagkey, category, data = tag_tuple - out = ("Tag: '{tagkey}' (category: {category}{})".format( - tagkey=tagkey, category=category, data=", data: {}".format(data) if data else "")) + out = ("Tag: '{tagkey}' (category: {category}{dat})".format( + tagkey=tagkey, category=category, dat=", data: {}".format(data) if data else "")) return out @@ -613,16 +615,21 @@ def _add_tag(caller, tag, **kwargs): old_tag = kwargs.get("edit", None) - if old_tag: - # editing a tag means removing the old and replacing with new + if not old_tag: + # a fresh, new tag + tags.append(tag_tuple) + else: + # old tag exists; editing a tag means removing the old and replacing with new try: ind = [tup[0] for tup in tags].index(old_tag) del tags[ind] + if tags: + tags.insert(ind, tag_tuple) + else: + tags = [tag_tuple] except IndexError: pass - tags.append(tag_tuple) - _set_prototype_value(caller, "tags", tags) text = kwargs.get('text') @@ -814,18 +821,121 @@ def node_prototype_locks(caller): return text, options +def _update_spawned(caller, **kwargs): + """update existing objects""" + prototype = kwargs['prototype'] + objects = kwargs['objects'] + back_node = kwargs['back_key'] + num_changed = spawner.batch_update_objects_with_prototype(prototype, objects=objects) + caller.msg("|g{num} objects were updated successfully.|n".format(num=num_changed)) + return back_key + + +def _keep_diff(caller, **kwargs): + key = kwargs['key'] + diff = kwargs['diff'] + diff[key] = "KEEP" + + +def node_update_objects(caller, **kwargs): + """Offer options for updating objects""" + + def _keep_option(keyname, prototype, obj, obj_prototype, diff, objects, back_node): + """helper returning an option dict""" + options = {"desc": "Keep {} as-is".format(keyname), + "goto": (_keep_diff, + {"key": keyname, "prototype": prototype, + "obj": obj, "obj_prototype": obj_prototype, + "diff": diff, "objects": objects, "back_node": back_node})} + return options + + prototype = kwargs.get("prototype", None) + update_objects = kwargs.get("objects", None) + back_node = kwargs.get("back_node", "node_index") + obj_prototype = kwargs.get("obj_prototype", None) + diff = kwargs.get("diff", None) + + if not update_objects: + text = "There are no existing objects to update." + options = {"key": "_default", + "goto": back_node} + return text, options + + if not diff: + # use one random object as a reference to calculate a diff + obj = choice(update_objects) + diff, obj_prototype = spawner.prototype_diff_from_object(prototype, obj) + + text = ["Suggested changes to {} objects".format(len(update_objects)), + "Showing random example obj to change: {name} (#{dbref}))\n".format(obj.key, obj.dbref)] + options = [] + io = 0 + for (key, inst) in sorted(((key, val) for key, val in diff.items()), key=lambda tup: tup[0]): + line = "{iopt} |w{key}|n: {old}{sep}{new} {change}" + old_val = utils.crop(str(obj_prototype[key]), width=20) + + if inst == "KEEP": + text.append(line.format(iopt='', key=key, old=old_val, sep=" ", new='', change=inst)) + continue + + new_val = utils.crop(str(spawner.init_spawn_value(prototype[key])), width=20) + io += 1 + if inst in ("UPDATE", "REPLACE"): + text.append(line.format(iopt=io, key=key, old=old_val, + sep=" |y->|n ", new=new_val, change=inst)) + options.append(_keep_option(key, prototype, + obj, obj_prototype, diff, objects, back_node)) + elif inst == "REMOVE": + text.append(line.format(iopt=io, key=key, old=old_val, + sep=" |r->|n ", new='', change=inst)) + options.append(_keep_option(key, prototype, + obj, obj_prototype, diff, objects, back_node)) + options.extend( + [{"key": ("|wu|r update {} objects".format(len(update_objects)), "update", "u"), + "goto": (_update_spawned, {"prototype": prototype, "objects": objects, + "back_node": back_node, "diff": diff})}, + {"key": ("|wr|neset changes", "reset", "r"), + "goto": ("node_update_objects", {"prototype": prototype, "back_node": back_node, + "objects": update_objects})}, + {"key": "|wb|rack ({})".format(back_node[5:], 'b'), + "goto": back_node}]) + + return text, options + + def node_prototype_save(caller, **kwargs): """Save prototype to disk """ # these are only set if we selected 'yes' to save on a previous pass - accept_save = kwargs.get("accept", False) prototype = kwargs.get("prototype", None) + accept_save = kwargs.get("accept_save", False) if accept_save and prototype: # we already validated and accepted the save, so this node acts as a goto callback and # should now only return the next node + prototype_key = prototype.get("prototype_key") protlib.save_prototype(**prototype) - caller.msg("|gPrototype saved.|n") - return "node_spawn" + + spawned_objects = protlib.search_objects_with_prototype(prototype_key) + nspawned = spawned_objects.count() + + if nspawned: + text = ("Do you want to update {} object(s) " + "already using this prototype?".format(nspawned)) + options = ( + {"key": ("|wY|Wes|n", "yes", "y"), + "goto": ("node_update_objects", + {"accept_update": True, "objects": spawned_objects, + "prototype": prototype, "back_node": "node_prototype_save"})}, + {"key": ("[|wN|Wo|n]", "n"), + "goto": "node_spawn"}, + {"key": "_default", + "goto": "node_spawn"}) + else: + text = "|gPrototype saved.|n" + options = {"key": "_default", + "goto": "node_spawn"} + + return text, options # not validated yet prototype = _get_menu_prototype(caller) @@ -850,15 +960,13 @@ def node_prototype_save(caller, **kwargs): options = ( {"key": ("[|wY|Wes|n]", "yes", "y"), - "goto": lambda caller: - node_prototype_save(caller, - {"accept": True, "prototype": prototype})}, + "goto": ("node_prototype_save", + {"accept": True, "prototype": prototype})}, {"key": ("|wN|Wo|n", "n"), "goto": "node_spawn"}, {"key": "_default", - "goto": lambda caller: - node_prototype_save(caller, - {"accept": True, "prototype": prototype})}) + "goto": ("node_prototype_save", + {"accept": True, "prototype": prototype})}) return "\n".join(text), options @@ -869,20 +977,15 @@ def _spawn(caller, **kwargs): new_location = kwargs.get('location', None) if new_location: prototype['location'] = new_location + obj = spawner.spawn(prototype) if obj: + obj = obj[0] caller.msg("|gNew instance|n {key} ({dbref}) |gspawned.|n".format( key=obj.key, dbref=obj.dbref)) else: caller.msg("|rError: Spawner did not return a new instance.|n") - - -def _update_spawned(caller, **kwargs): - """update existing objects""" - prototype = kwargs['prototype'] - objects = kwargs['objects'] - num_changed = spawner.batch_update_objects_with_prototype(prototype, objects=objects) - caller.msg("|g{num} objects were updated successfully.|n".format(num=num_changed)) + return obj def node_prototype_spawn(caller, **kwargs): @@ -926,9 +1029,9 @@ def node_prototype_spawn(caller, **kwargs): if spawned_objects: options.append( {"desc": "Update {num} existing objects with this prototype".format(num=nspawned), - "goto": (_update_spawned, - dict(prototype=prototype, - opjects=spawned_objects))}) + "goto": ("node_update_objects", + dict(prototype=prototype, opjects=spawned_objects, + back_node="node_prototype_spawn"))}) options.extend(_wizard_options("prototype_spawn", "prototype_save", "index")) return text, options @@ -1008,6 +1111,7 @@ def start_olc(caller, session=None, prototype=None): "node_location": node_location, "node_home": node_home, "node_destination": node_destination, + "node_update_objects": node_o "node_prototype_desc": node_prototype_desc, "node_prototype_tags": node_prototype_tags, "node_prototype_locks": node_prototype_locks, diff --git a/evennia/prototypes/prototypes.py b/evennia/prototypes/prototypes.py index c02041d8bc..2457f86994 100644 --- a/evennia/prototypes/prototypes.py +++ b/evennia/prototypes/prototypes.py @@ -547,7 +547,8 @@ def validate_prototype(prototype, protkey=None, protparents=None, _flags = {"visited": [], "depth": 0, "typeclass": False, "errors": [], "warnings": []} if not protparents: - protparents = {prototype['prototype_key']: prototype for prototype in search_prototype()} + protparents = {prototype.get('prototype_key', "").lower(): prototype + for prototype in search_prototype()} protkey = protkey and protkey.lower() or prototype.get('prototype_key', None) @@ -568,17 +569,11 @@ def validate_prototype(prototype, protkey=None, protparents=None, if typeclass and typeclass not in get_all_typeclasses("evennia.objects.models.ObjectDB"): _flags['errors'].append( - "Prototype {} is based on typeclass {} which could not be imported!".format( + "Prototype {} is based on typeclass {}, which could not be imported!".format( protkey, typeclass)) # recursively traverese prototype_parent chain - if id(prototype) in _flags['visited']: - _flags['errors'].append( - "{} has infinite nesting of prototypes.".format(protkey or prototype)) - - _flags['visited'].append(id(prototype)) - for protstring in make_iter(prototype_parent): protstring = protstring.lower() if protkey is not None and protstring == protkey: @@ -587,8 +582,15 @@ def validate_prototype(prototype, protkey=None, protparents=None, if not protparent: _flags['errors'].append("Prototype {}'s prototype_parent '{}' was not found.".format( (protkey, protstring))) + if id(prototype) in _flags['visited']: + _flags['errors'].append( + "{} has infinite nesting of prototypes.".format(protkey or prototype)) + + _flags['visited'].append(id(prototype)) _flags['depth'] += 1 - validate_prototype(protparent, protstring, protparents, _flags) + validate_prototype(protparent, protstring, protparents, + is_prototype_base=is_prototype_base, _flags=_flags) + _flags['visited'].pop() _flags['depth'] -= 1 if typeclass and not _flags['typeclass']: diff --git a/evennia/prototypes/spawner.py b/evennia/prototypes/spawner.py index 71aecfd61e..d826317fec 100644 --- a/evennia/prototypes/spawner.py +++ b/evennia/prototypes/spawner.py @@ -179,6 +179,7 @@ def prototype_from_object(obj): prot = obj.tags.get(category=_PROTOTYPE_TAG_CATEGORY, return_list=True) if prot: prot = protlib.search_prototype(prot[0]) + if not prot or len(prot) > 1: # no unambiguous prototype found - build new prototype prot = {} @@ -187,6 +188,8 @@ def prototype_from_object(obj): prot['prototype_desc'] = "Built from {}".format(str(obj)) prot['prototype_locks'] = "spawn:all();edit:all()" prot['prototype_tags'] = [] + else: + prot = prot[0] prot['key'] = obj.db_key or hashlib.md5(str(time.time())).hexdigest()[:6] prot['typeclass'] = obj.db_typeclass_path @@ -233,6 +236,8 @@ def prototype_diff_from_object(prototype, obj): Returns: diff (dict): Mapping for every prototype key: {"keyname": "REMOVE|UPDATE|KEEP", ...} + other_prototype (dict): The prototype for the given object. The diff is a how to convert + this prototype into the new prototype. """ prot1 = prototype @@ -253,7 +258,7 @@ def prototype_diff_from_object(prototype, obj): if key not in diff and key not in prot1: diff[key] = "REMOVE" - return diff + return diff, prot2 def batch_update_objects_with_prototype(prototype, diff=None, objects=None): @@ -475,8 +480,12 @@ def spawn(*prototypes, **kwargs): protparents = {prot['prototype_key'].lower(): prot for prot in protlib.search_prototype()} # overload module's protparents with specifically given protparents - protparents.update( - {key.lower(): value for key, value in kwargs.get("prototype_parents", {}).items()}) + # we allow prototype_key to be the key of the protparent dict, to allow for module-level + # prototype imports. We need to insert prototype_key in this case + for key, protparent in kwargs.get("prototype_parents", {}).items(): + key = str(key).lower() + protparent['prototype_key'] = str(protparent.get("prototype_key", key)).lower() + protparents[key] = protparent if "return_parents" in kwargs: # only return the parents @@ -541,6 +550,9 @@ def spawn(*prototypes, **kwargs): simple_attributes = [] for key, value in ((key, value) for key, value in prot.items() if not (key.startswith("ndb_"))): + if key in _PROTOTYPE_META_NAMES: + continue + if is_iter(value) and len(value) > 1: # (value, category) simple_attributes.append((key, diff --git a/evennia/prototypes/tests.py b/evennia/prototypes/tests.py index 0d5e247378..69eb495dd5 100644 --- a/evennia/prototypes/tests.py +++ b/evennia/prototypes/tests.py @@ -17,6 +17,8 @@ from evennia.prototypes.prototypes import _PROTOTYPE_TAG_META_CATEGORY _PROTPARENTS = { "NOBODY": {}, "GOBLIN": { + "prototype_key": "GOBLIN", + "typeclass": "evennia.objects.objects.DefaultObject", "key": "goblin grunt", "health": lambda: randint(1, 1), "resists": ["cold", "poison"], @@ -24,21 +26,22 @@ _PROTPARENTS = { "weaknesses": ["fire", "light"] }, "GOBLIN_WIZARD": { - "prototype": "GOBLIN", + "prototype_parent": "GOBLIN", "key": "goblin wizard", "spells": ["fire ball", "lighting bolt"] }, "GOBLIN_ARCHER": { - "prototype": "GOBLIN", + "prototype_parent": "GOBLIN", "key": "goblin archer", "attacks": ["short bow"] }, "ARCHWIZARD": { + "prototype_parent": "GOBLIN", "attacks": ["archwizard staff"], }, "GOBLIN_ARCHWIZARD": { "key": "goblin archwizard", - "prototype": ("GOBLIN_WIZARD", "ARCHWIZARD") + "prototype_parent": ("GOBLIN_WIZARD", "ARCHWIZARD") } } @@ -47,7 +50,8 @@ class TestSpawner(EvenniaTest): def setUp(self): super(TestSpawner, self).setUp() - self.prot1 = {"prototype_key": "testprototype"} + self.prot1 = {"prototype_key": "testprototype", + "typeclass": "evennia.objects.objects.DefaultObject"} def test_spawn(self): obj1 = spawner.spawn(self.prot1) @@ -323,6 +327,7 @@ class TestMenuModule(EvenniaTest): self.caller.ndb._menutree = menutree self.test_prot = {"prototype_key": "test_prot", + "typeclass": "evennia.objects.objects.DefaultObject", "prototype_locks": "edit:all();spawn:all()"} def test_helpers(self): @@ -334,6 +339,8 @@ class TestMenuModule(EvenniaTest): self.assertEqual(olc_menus._get_menu_prototype(caller), {}) self.assertEqual(olc_menus._is_new_prototype(caller), True) + self.assertEqual(olc_menus._set_menu_prototype(caller, {}), {}) + self.assertEqual( olc_menus._set_prototype_value(caller, "key", "TestKey"), {"key": "TestKey"}) self.assertEqual(olc_menus._get_menu_prototype(caller), {"key": "TestKey"}) @@ -349,13 +356,16 @@ class TestMenuModule(EvenniaTest): self.assertEqual(olc_menus._wizard_options( "ThisNode", "PrevNode", "NextNode"), - [{'goto': 'node_PrevNode', 'key': ('|wb|Wack', 'b'), 'desc': '|W(PrevNode)|n'}, - {'goto': 'node_NextNode', 'key': ('|wf|Worward', 'f'), 'desc': '|W(NextNode)|n'}, - {'goto': 'node_index', 'key': ('|wi|Wndex', 'i')}, + [{'goto': 'node_PrevNode', 'key': ('|wB|Wack', 'b'), 'desc': '|W(PrevNode)|n'}, + {'goto': 'node_NextNode', 'key': ('|wF|Worward', 'f'), 'desc': '|W(NextNode)|n'}, + {'goto': 'node_index', 'key': ('|wI|Wndex', 'i')}, {'goto': ('node_validate_prototype', {'back': 'ThisNode'}), - 'key': ('|wv|Walidate prototype', 'v')}]) + 'key': ('|wV|Walidate prototype', 'validate', 'v')}]) - self.assertEqual(olc_menus._validate_prototype(self.test_prot, (False, Something))) + self.assertEqual(olc_menus._validate_prototype(self.test_prot), (False, Something)) + self.assertEqual(olc_menus._validate_prototype( + {"prototype_key": "testthing", "key": "mytest"}), + (True, Something)) def test_node_helpers(self): @@ -363,23 +373,27 @@ class TestMenuModule(EvenniaTest): with mock.patch("evennia.prototypes.menus.protlib.search_prototype", new=mock.MagicMock(return_value=[self.test_prot])): + # prototype_key helpers self.assertEqual(olc_menus._check_prototype_key(caller, "test_prot"), "node_prototype_parent") caller.ndb._menutree.olc_new = True self.assertEqual(olc_menus._check_prototype_key(caller, "test_prot"), "node_index") + # prototype_parent helpers self.assertEqual(olc_menus._all_prototype_parents(caller), ['test_prot']) self.assertEqual(olc_menus._prototype_parent_examine( caller, 'test_prot'), - '|cprototype key:|n test_prot, |ctags:|n None, |clocks:|n edit:all();spawn:all() ' - '\n|cdesc:|n None \n|cprototype:|n {\n \n}') + "|cprototype key:|n test_prot, |ctags:|n None, |clocks:|n edit:all();spawn:all() " + "\n|cdesc:|n None \n|cprototype:|n " + "{\n 'typeclass': 'evennia.objects.objects.DefaultObject', \n}") self.assertEqual(olc_menus._prototype_parent_select(caller, self.test_prot), "node_key") self.assertEqual(olc_menus._get_menu_prototype(caller), {'prototype_key': 'test_prot', 'prototype_locks': 'edit:all();spawn:all()', - 'prototype_parent': "test_prot"}) + 'typeclass': 'evennia.objects.objects.DefaultObject'}) + # typeclass helpers with mock.patch("evennia.utils.utils.get_all_typeclasses", new=mock.MagicMock(return_value={"foo": None, "bar": None})): self.assertEqual(olc_menus._all_typeclasses(caller), ["bar", "foo"]) @@ -394,6 +408,53 @@ class TestMenuModule(EvenniaTest): 'prototype_locks': 'edit:all();spawn:all()', 'typeclass': 'evennia.objects.objects.DefaultObject'}) + # attr helpers + self.assertEqual(olc_menus._caller_attrs(caller), []) + self.assertEqual(olc_menus._add_attr(caller, "test1=foo1"), (Something, {"key": "_default", "goto": Something})) + self.assertEqual(olc_menus._add_attr(caller, "test2;cat1=foo2"), (Something, {"key": "_default", "goto": Something})) + self.assertEqual(olc_menus._add_attr(caller, "test3;cat2;edit:false()=foo3"), (Something, {"key": "_default", "goto": Something})) + self.assertEqual(olc_menus._add_attr(caller, "test4;cat3;set:true();edit:false()=foo4"), (Something, {"key": "_default", "goto": Something})) + self.assertEqual(olc_menus._add_attr(caller, "test5;cat4;set:true();edit:false()=123"), (Something, {"key": "_default", "goto": Something})) + self.assertEqual(olc_menus._caller_attrs( + caller), + [("test1", "foo1", None, ''), + ("test2", "foo2", "cat1", ''), + ("test3", "foo3", "cat2", "edit:false()"), + ("test4", "foo4", "cat3", "set:true();edit:false()"), + ("test5", '123', "cat4", "set:true();edit:false()")]) + self.assertEqual(olc_menus._edit_attr(caller, "test1", "1;cat5;edit:all()"), (Something, {"key": "_default", "goto": Something})) + self.assertEqual(olc_menus._examine_attr(caller, "test1"), Something) + + # tag helpers + self.assertEqual(olc_menus._caller_tags(caller), []) + self.assertEqual(olc_menus._add_tag(caller, "foo1"), (Something, {"key": "_default", "goto": Something})) + self.assertEqual(olc_menus._add_tag(caller, "foo2;cat1"), (Something, {"key": "_default", "goto": Something})) + self.assertEqual(olc_menus._add_tag(caller, "foo3;cat2;dat1"), (Something, {"key": "_default", "goto": Something})) + self.assertEqual(olc_menus._caller_tags( + caller), + [('foo1', None, ""), + ('foo2', 'cat1', ""), + ('foo3', 'cat2', "dat1")]) + self.assertEqual(olc_menus._edit_tag(caller, "foo1", "bar1;cat1"), (Something, {"key": "_default", "goto": Something})) + self.assertEqual(olc_menus._display_tag(olc_menus._caller_tags(caller)[0]), Something) + self.assertEqual(olc_menus._caller_tags(caller)[0], ("bar1", "cat1", "")) + + protlib.save_prototype(**self.test_prot) + + # spawn helpers + obj = olc_menus._spawn(caller, prototype=self.test_prot) + + self.assertEqual(obj.typeclass_path, "evennia.objects.objects.DefaultObject") + self.assertEqual(obj.tags.get(category=spawner._PROTOTYPE_TAG_CATEGORY), self.test_prot['prototype_key']) + self.assertEqual(olc_menus._update_spawned(caller, prototype=self.test_prot, objects=[obj]), 0) # no changes to apply + self.test_prot['key'] = "updated key" # change prototype + self.assertEqual(self._update_spawned(caller, prototype=self.test_prot, objects=[obj]), 1) # apply change to the one obj + + + # load helpers + + + @mock.patch("evennia.prototypes.menus.protlib.search_prototype", new=mock.MagicMock( return_value=[{"prototype_key": "TestPrototype", From 178a2c8c8492fe46266eeaf07de6d9c887ebbc20 Mon Sep 17 00:00:00 2001 From: Griatch Date: Wed, 4 Jul 2018 19:25:44 +0200 Subject: [PATCH 161/208] Add unit tests to all menu helpers --- evennia/prototypes/menus.py | 15 ++++++++------- evennia/prototypes/spawner.py | 2 +- evennia/prototypes/tests.py | 11 +++++++---- 3 files changed, 16 insertions(+), 12 deletions(-) diff --git a/evennia/prototypes/menus.py b/evennia/prototypes/menus.py index af670b743a..1f2eb26a4f 100644 --- a/evennia/prototypes/menus.py +++ b/evennia/prototypes/menus.py @@ -825,10 +825,11 @@ def _update_spawned(caller, **kwargs): """update existing objects""" prototype = kwargs['prototype'] objects = kwargs['objects'] - back_node = kwargs['back_key'] - num_changed = spawner.batch_update_objects_with_prototype(prototype, objects=objects) + back_node = kwargs['back_node'] + diff = kwargs.get('diff', None) + num_changed = spawner.batch_update_objects_with_prototype(prototype, diff=diff, objects=objects) caller.msg("|g{num} objects were updated successfully.|n".format(num=num_changed)) - return back_key + return back_node def _keep_diff(caller, **kwargs): @@ -884,15 +885,15 @@ def node_update_objects(caller, **kwargs): text.append(line.format(iopt=io, key=key, old=old_val, sep=" |y->|n ", new=new_val, change=inst)) options.append(_keep_option(key, prototype, - obj, obj_prototype, diff, objects, back_node)) + obj, obj_prototype, diff, update_objects, back_node)) elif inst == "REMOVE": text.append(line.format(iopt=io, key=key, old=old_val, sep=" |r->|n ", new='', change=inst)) options.append(_keep_option(key, prototype, - obj, obj_prototype, diff, objects, back_node)) + obj, obj_prototype, diff, update_objects, back_node)) options.extend( [{"key": ("|wu|r update {} objects".format(len(update_objects)), "update", "u"), - "goto": (_update_spawned, {"prototype": prototype, "objects": objects, + "goto": (_update_spawned, {"prototype": prototype, "objects": update_objects, "back_node": back_node, "diff": diff})}, {"key": ("|wr|neset changes", "reset", "r"), "goto": ("node_update_objects", {"prototype": prototype, "back_node": back_node, @@ -1111,7 +1112,7 @@ def start_olc(caller, session=None, prototype=None): "node_location": node_location, "node_home": node_home, "node_destination": node_destination, - "node_update_objects": node_o + "node_update_objects": node_update_objects, "node_prototype_desc": node_prototype_desc, "node_prototype_tags": node_prototype_tags, "node_prototype_locks": node_prototype_locks, diff --git a/evennia/prototypes/spawner.py b/evennia/prototypes/spawner.py index d826317fec..c09a192819 100644 --- a/evennia/prototypes/spawner.py +++ b/evennia/prototypes/spawner.py @@ -290,7 +290,7 @@ def batch_update_objects_with_prototype(prototype, diff=None, objects=None): return 0 if not diff: - diff = prototype_diff_from_object(new_prototype, objects[0]) + diff, _ = prototype_diff_from_object(new_prototype, objects[0]) changed = 0 for obj in objects: diff --git a/evennia/prototypes/tests.py b/evennia/prototypes/tests.py index 69eb495dd5..8932b368c1 100644 --- a/evennia/prototypes/tests.py +++ b/evennia/prototypes/tests.py @@ -446,13 +446,16 @@ class TestMenuModule(EvenniaTest): self.assertEqual(obj.typeclass_path, "evennia.objects.objects.DefaultObject") self.assertEqual(obj.tags.get(category=spawner._PROTOTYPE_TAG_CATEGORY), self.test_prot['prototype_key']) - self.assertEqual(olc_menus._update_spawned(caller, prototype=self.test_prot, objects=[obj]), 0) # no changes to apply - self.test_prot['key'] = "updated key" # change prototype - self.assertEqual(self._update_spawned(caller, prototype=self.test_prot, objects=[obj]), 1) # apply change to the one obj + # update helpers + self.assertEqual(olc_menus._update_spawned( + caller, prototype=self.test_prot, back_node="foo", objects=[obj]), 'foo') # no changes to apply + self.test_prot['key'] = "updated key" # change prototype + self.assertEqual(olc_menus._update_spawned( + caller, prototype=self.test_prot, objects=[obj], back_node='foo'), 'foo') # apply change to the one obj # load helpers - + self.assertEqual(olc_menus._prototype_load_select(caller, self.test_prot['prototype_key']), "node_index") From 040a94b1ee9fe8f7a8510276a29df4d788081fca Mon Sep 17 00:00:00 2001 From: Jerry Aldrich Date: Mon, 9 Jul 2018 08:33:47 -0700 Subject: [PATCH 162/208] Add space to the tutorial's CrumblingWall message Signed-off-by: Jerry Aldrich --- evennia/contrib/tutorial_world/objects.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/evennia/contrib/tutorial_world/objects.py b/evennia/contrib/tutorial_world/objects.py index b260770577..0561f98d03 100644 --- a/evennia/contrib/tutorial_world/objects.py +++ b/evennia/contrib/tutorial_world/objects.py @@ -674,7 +674,7 @@ class CrumblingWall(TutorialObject, DefaultExit): # we found the button by moving the roots result = ["Having moved all the roots aside, you find that the center of the wall, " "previously hidden by the vegetation, hid a curious square depression. It was maybe once " - "concealed and made to look a part of the wall, but with the crumbling of stone around it," + "concealed and made to look a part of the wall, but with the crumbling of stone around it, " "it's now easily identifiable as some sort of button."] elif self.db.exit_open: # we pressed the button; the exit is open From 7ac113a3e12bf8b5738c9b155a537fc728629a24 Mon Sep 17 00:00:00 2001 From: Griatch Date: Thu, 12 Jul 2018 10:18:04 +0200 Subject: [PATCH 163/208] More work on unittests, still issues --- evennia/commands/default/building.py | 86 +++++++++------------------- evennia/commands/default/tests.py | 26 +++++---- evennia/prototypes/prototypes.py | 13 ++++- 3 files changed, 56 insertions(+), 69 deletions(-) diff --git a/evennia/commands/default/building.py b/evennia/commands/default/building.py index 5c96ad1cf6..a589b5131e 100644 --- a/evennia/commands/default/building.py +++ b/evennia/commands/default/building.py @@ -2795,17 +2795,17 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): spawn objects from prototype Usage: - @spawn[/noloc] + @spawn[/noloc] @spawn[/noloc] - @spawn/search [key][;tag[,tag]] - @spawn/list [tag, tag] - @spawn/show [] - @spawn/update + @spawn/search [prototype_keykey][;tag[,tag]] + @spawn/list [tag, tag, ...] + @spawn/show [] + @spawn/update - @spawn/save [;desc[;tag,tag[,...][;lockstring]]] = - @spawn/menu [] - @olc - equivalent to @spawn/menu + @spawn/save + @spawn/edit [] + @olc - equivalent to @spawn/edit Switches: noloc - allow location to be None if not specified explicitly. Otherwise, @@ -2819,7 +2819,7 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): them with latest version of given prototype. If given with /save, will auto-update all objects with the old version of the prototype without asking first. - menu, olc - create/manipulate prototype in a menu interface. + edit, olc - create/manipulate prototype in a menu interface. Example: @spawn GOBLIN @@ -2827,10 +2827,11 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): @spawn/save {"key": "grunt", prototype: "goblin"};;mobs;edit:all() Dictionary keys: - |wprototype |n - name of parent prototype to use. Can be a list for - multiple inheritance (inherits left to right) + |wprototype_parent |n - name of parent prototype to use. Required if typeclass is + not set. Can be a path or a list for multiple inheritance (inherits + left to right). If set one of the parents must have a typeclass. + |wtypeclass |n - string. Required if prototype_parent is not set. |wkey |n - string, the main object identifier - |wtypeclass |n - string, if not set, will use settings.BASE_OBJECT_TYPECLASS |wlocation |n - this should be a valid object or #dbref |whome |n - valid object or #dbref |wdestination|n - only valid for exits (object or dbref) @@ -2875,7 +2876,8 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): string = ("{}\n|RCritical Python syntax error in argument. Only primitive " "Python structures are allowed. \nYou also need to use correct " "Python syntax. Remember especially to put quotes around all " - "strings inside lists and dicts.|n".format(err)) + "strings inside lists and dicts.|n For more advanced uses, embed " + "inline functions in the strings.".format(err)) else: string = "Expected {}, got {}.".format(expect, type(prototype)) self.caller.msg(string) @@ -2896,9 +2898,9 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): def _search_show_prototype(query, prototypes=None): # prototype detail if not prototypes: - prototypes = spawner.search_prototype(key=query) + prototypes = protlib.search_prototype(key=query) if prototypes: - return "\n".join(spawner.prototype_to_str(prot) for prot in prototypes) + return "\n".join(protlib.prototype_to_str(prot) for prot in prototypes) else: return False @@ -2947,64 +2949,36 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): if 'list' in self.switches: # for list, all optional arguments are tags + # import pudb; pudb.set_trace() + EvMore(caller, unicode(protlib.list_prototypes(caller, tags=self.lhslist)), exit_on_lastpage=True) return if 'save' in self.switches: # store a prototype to the database store - if not self.args or not self.rhs: + if not self.args: caller.msg( "Usage: @spawn/save [;desc[;tag,tag[,...][;lockstring]]] = ") return - # handle lhs - parts = self.lhs.split(";", 3) - nparts = len(parts) - if nparts == 1: - key = parts[0].strip() - elif nparts == 2: - key, desc = (part.strip() for part in parts) - elif nparts == 3: - key, desc, tags = (part.strip() for part in parts) - tags = [tag.strip().lower() for tag in tags.split(",") if tag] - else: - # lockstrings can itself contain ; - key, desc, tags, lockstring = (part.strip() for part in parts) - tags = [tag.strip().lower() for tag in tags.split(",") if tag] - if not key: - caller.msg("The prototype must have a key.") - return - if not desc: - desc = "User-created prototype" - if not tags: - tags = ["user"] - if not lockstring: - lockstring = "edit:id({}) or perm(Admin); use:all()".format(caller.id) - - is_valid, err = caller.locks.validate(lockstring) - if not is_valid: - caller.msg("|rLock error|n: {}".format(err)) - return - # handle rhs: - prototype = _parse_prototype(self.rhs) + prototype = _parse_prototype(self.lhs.strip()) if not prototype: return - # inject the prototype_* keys into the prototype to save - prototype['prototype_key'] = prototype.get('prototype_key', key) - prototype['prototype_desc'] = prototype.get('prototype_desc', desc) - prototype['prototype_tags'] = prototype.get('prototype_tags', tags) - prototype['prototype_locks'] = prototype.get('prototype_locks', lockstring) - # present prototype to save new_matchstring = _search_show_prototype("", prototypes=[prototype]) string = "|yCreating new prototype:|n\n{}".format(new_matchstring) question = "\nDo you want to continue saving? [Y]/N" + prototype_key = prototype.get("prototype_key") + if not prototype_key: + caller.msg("\n|yTo save a prototype it must have the 'prototype_key' set.") + return + # check for existing prototype, - old_matchstring = _search_show_prototype(key) + old_matchstring = _search_show_prototype(prototype_key) if old_matchstring: string += "\n|yExisting saved prototype found:|n\n{}".format(old_matchstring) @@ -3017,14 +2991,10 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): # all seems ok. Try to save. try: - prot = spawner.save_db_prototype( - caller, key, prototype, desc=desc, tags=tags, locks=lockstring) + prot = protlib.save_prototype(**prototype) if not prot: caller.msg("|rError saving:|R {}.|n".format(key)) return - prot.locks.append("edit", "perm(Admin)") - if not prot.locks.get("use"): - prot.locks.add("use:all()") except PermissionError as err: caller.msg("|rError saving:|R {}|n".format(err)) return diff --git a/evennia/commands/default/tests.py b/evennia/commands/default/tests.py index e1688cdb48..f047b3a458 100644 --- a/evennia/commands/default/tests.py +++ b/evennia/commands/default/tests.py @@ -360,6 +360,7 @@ class TestBuilding(CommandTest): # check that it exists in the process. query = search_object(objKeyStr) commandTest.assertIsNotNone(query) + commandTest.assertTrue(bool(query)) obj = query[0] commandTest.assertIsNotNone(obj) return obj @@ -368,18 +369,20 @@ class TestBuilding(CommandTest): self.call(building.CmdSpawn(), " ", "Usage: @spawn") # Tests "@spawn " without specifying location. - self.call(building.CmdSpawn(), - "{'prototype_key': 'testprot', 'key':'goblin', " - "'typeclass':'evennia.DefaultCharacter'}", "Spawned goblin") - goblin = getObject(self, "goblin") + with mock.patch('evennia.commands.default.func', return_value=iter(['y'])) as mock_iter: + self.call(building.CmdSpawn(), + "/save {'prototype_key': 'testprot', 'key':'Test Char', " + "'typeclass':'evennia.objects.objects.DefaultCharacter'}", "") + mock_iter.assert_called() - # Tests that the spawned object's type is a DefaultCharacter. - self.assertIsInstance(goblin, DefaultCharacter) + self.call(building.CmdSpawn(), "/list", "foo") + self.call(building.CmdSpawn(), 'testprot', "Spawned Test Char") # Tests that the spawned object's location is the same as the caharacter's location, since # we did not specify it. - self.assertEqual(goblin.location, self.char1.location) - goblin.delete() + testchar = getObject(self, "Test Char") + self.assertEqual(testchar.location, self.char1.location) + testchar.delete() # Test "@spawn " with a location other than the character's. spawnLoc = self.room2 @@ -389,10 +392,13 @@ class TestBuilding(CommandTest): spawnLoc = self.room1 self.call(building.CmdSpawn(), - "{'prototype_key':'GOBLIN', 'key':'goblin', 'location':'%s'}" - % spawnLoc.dbref, "Spawned goblin") + "{'prototype_key':'GOBLIN', 'typeclass':'evennia.objects.objects.DefaultCharacter', " + "'key':'goblin', 'location':'%s'}" % spawnLoc.dbref, "Spawned goblin") goblin = getObject(self, "goblin") + # Tests that the spawned object's type is a DefaultCharacter. + self.assertIsInstance(goblin, DefaultCharacter) self.assertEqual(goblin.location, spawnLoc) + goblin.delete() protlib.create_prototype(**{'key': 'Ball', 'prototype': 'GOBLIN', 'prototype_key': 'testball'}) diff --git a/evennia/prototypes/prototypes.py b/evennia/prototypes/prototypes.py index 2457f86994..57087b133f 100644 --- a/evennia/prototypes/prototypes.py +++ b/evennia/prototypes/prototypes.py @@ -506,7 +506,7 @@ def list_prototypes(caller, key=None, tags=None, show_non_use=False, show_non_ed ",".join(ptags))) if not display_tuples: - return None + return "" table = [] width = 78 @@ -607,3 +607,14 @@ def validate_prototype(prototype, protkey=None, protparents=None, raise RuntimeError("Error: " + "\nError: ".join(_flags['errors'])) if _flags['warnings']: raise RuntimeWarning("Warning: " + "\nWarning: ".join(_flags['warnings'])) + + # make sure prototype_locks are set to defaults + prototype_locks = [lstring.split(":", 1) + for lstring in prototype.get("prototype_locks", "").split(';')] + locktypes = [tup[0].strip() for tup in prototype_locks] + if "spawn" not in locktypes: + prototype_locks.append(("spawn", "all()")) + if "edit" not in locktypes: + prototype_locks.append(("edit", "all()")) + prototype_locks = ";".join(":".join(tup) for tup in prototype_locks) + prototype['prototype_locks'] = prototype_locks From 5db7c1dfbb2f81dea93a2e2e43d888db7a41d3be Mon Sep 17 00:00:00 2001 From: Griatch Date: Sat, 21 Jul 2018 13:40:46 +0200 Subject: [PATCH 164/208] Fix unittests --- evennia/commands/default/building.py | 12 +-- evennia/commands/default/tests.py | 49 +++++++++--- evennia/contrib/tutorial_world/objects.py | 24 +++--- evennia/prototypes/prototypes.py | 10 ++- evennia/prototypes/tests.py | 93 ++++++++++++++++------- 5 files changed, 128 insertions(+), 60 deletions(-) diff --git a/evennia/commands/default/building.py b/evennia/commands/default/building.py index a589b5131e..cdcbadc103 100644 --- a/evennia/commands/default/building.py +++ b/evennia/commands/default/building.py @@ -2993,22 +2993,22 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): try: prot = protlib.save_prototype(**prototype) if not prot: - caller.msg("|rError saving:|R {}.|n".format(key)) + caller.msg("|rError saving:|R {}.|n".format(prototype_key)) return - except PermissionError as err: + except protlib.PermissionError as err: caller.msg("|rError saving:|R {}|n".format(err)) return - caller.msg("|gSaved prototype:|n {}".format(key)) + caller.msg("|gSaved prototype:|n {}".format(prototype_key)) # check if we want to update existing objects - existing_objects = spawner.search_objects_with_prototype(key) + existing_objects = protlib.search_objects_with_prototype(prototype_key) if existing_objects: if 'update' not in self.switches: n_existing = len(existing_objects) slow = " (note that this may be slow)" if n_existing > 10 else "" string = ("There are {} objects already created with an older version " "of prototype {}. Should it be re-applied to them{}? [Y]/N".format( - n_existing, key, slow)) + n_existing, prototype_key, slow)) answer = yield(string) if answer.lower() in ["n", "no"]: caller.msg("|rNo update was done of existing objects. " @@ -3036,7 +3036,7 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): return try: success = protlib.delete_db_prototype(caller, self.args) - except PermissionError as err: + except protlib.PermissionError as err: caller.msg("|rError deleting:|R {}|n".format(err)) caller.msg("Deletion {}.".format( 'successful' if success else 'failed (does the prototype exist?)')) diff --git a/evennia/commands/default/tests.py b/evennia/commands/default/tests.py index f047b3a458..709e7154ba 100644 --- a/evennia/commands/default/tests.py +++ b/evennia/commands/default/tests.py @@ -28,7 +28,7 @@ from evennia.utils import ansi, utils, gametime from evennia.server.sessionhandler import SESSIONS from evennia import search_object from evennia import DefaultObject, DefaultCharacter -from evennia.prototypes import spawner, prototypes as protlib +from evennia.prototypes import prototypes as protlib # set up signal here since we are not starting the server @@ -46,7 +46,7 @@ class CommandTest(EvenniaTest): Tests a command """ def call(self, cmdobj, args, msg=None, cmdset=None, noansi=True, caller=None, - receiver=None, cmdstring=None, obj=None): + receiver=None, cmdstring=None, obj=None, inputs=None): """ Test a command by assigning all the needed properties to cmdobj and running @@ -75,14 +75,31 @@ class CommandTest(EvenniaTest): cmdobj.obj = obj or (caller if caller else self.char1) # test old_msg = receiver.msg + inputs = inputs or [] + try: receiver.msg = Mock() if cmdobj.at_pre_cmd(): return cmdobj.parse() ret = cmdobj.func() + + # handle func's with yield in them (generators) if isinstance(ret, types.GeneratorType): - ret.next() + while True: + try: + inp = inputs.pop() if inputs else None + if inp: + try: + ret.send(inp) + except TypeError: + ret.next() + ret = ret.send(inp) + else: + ret.next() + except StopIteration: + break + cmdobj.at_post_cmd() except StopIteration: pass @@ -95,7 +112,7 @@ class CommandTest(EvenniaTest): # Get the first element of a tuple if msg received a tuple instead of a string stored_msg = [smsg[0] if isinstance(smsg, tuple) else smsg for smsg in stored_msg] if msg is not None: - returned_msg = "||".join(_RE.sub("", mess) for mess in stored_msg) + returned_msg = "||".join(_RE.sub("", str(mess)) for mess in stored_msg) returned_msg = ansi.parse_ansi(returned_msg, strip_ansi=noansi).strip() if msg == "" and returned_msg or not returned_msg.startswith(msg.strip()): sep1 = "\n" + "=" * 30 + "Wanted message" + "=" * 34 + "\n" @@ -369,13 +386,13 @@ class TestBuilding(CommandTest): self.call(building.CmdSpawn(), " ", "Usage: @spawn") # Tests "@spawn " without specifying location. - with mock.patch('evennia.commands.default.func', return_value=iter(['y'])) as mock_iter: - self.call(building.CmdSpawn(), - "/save {'prototype_key': 'testprot', 'key':'Test Char', " - "'typeclass':'evennia.objects.objects.DefaultCharacter'}", "") - mock_iter.assert_called() - self.call(building.CmdSpawn(), "/list", "foo") + self.call(building.CmdSpawn(), + "/save {'prototype_key': 'testprot', 'key':'Test Char', " + "'typeclass':'evennia.objects.objects.DefaultCharacter'}", + "Saved prototype: testprot", inputs=['y']) + + self.call(building.CmdSpawn(), "/list", "| Key ") self.call(building.CmdSpawn(), 'testprot', "Spawned Test Char") # Tests that the spawned object's location is the same as the caharacter's location, since @@ -401,10 +418,14 @@ class TestBuilding(CommandTest): goblin.delete() - protlib.create_prototype(**{'key': 'Ball', 'prototype': 'GOBLIN', 'prototype_key': 'testball'}) + # create prototype + protlib.create_prototype(**{'key': 'Ball', + 'typeclass': 'evennia.objects.objects.DefaultCharacter', + 'prototype_key': 'testball'}) # Tests "@spawn " self.call(building.CmdSpawn(), "testball", "Spawned Ball") + ball = getObject(self, "Ball") self.assertEqual(ball.location, self.char1.location) self.assertIsInstance(ball, DefaultObject) @@ -417,10 +438,14 @@ class TestBuilding(CommandTest): self.assertIsNone(ball.location) ball.delete() + self.call(building.CmdSpawn(), + "/noloc {'prototype_parent':'TESTBALL', 'prototype_key': 'testball', 'location':'%s'}" + % spawnLoc.dbref, "Error: Prototype testball tries to parent itself.") + # Tests "@spawn/noloc ...", but DO specify a location. # Location should be the specified location. self.call(building.CmdSpawn(), - "/noloc {'prototype':'TESTBALL', 'location':'%s'}" + "/noloc {'prototype_parent':'TESTBALL', 'key': 'Ball', 'prototype_key': 'foo', 'location':'%s'}" % spawnLoc.dbref, "Spawned Ball") ball = getObject(self, "Ball") self.assertEqual(ball.location, spawnLoc) diff --git a/evennia/contrib/tutorial_world/objects.py b/evennia/contrib/tutorial_world/objects.py index 807b4d5e09..1e088aa8d0 100644 --- a/evennia/contrib/tutorial_world/objects.py +++ b/evennia/contrib/tutorial_world/objects.py @@ -905,19 +905,19 @@ WEAPON_PROTOTYPES = { "magic": False, "desc": "A generic blade."}, "knife": { - "prototype": "weapon", + "prototype_parent": "weapon", "aliases": "sword", "key": "Kitchen knife", "desc": "A rusty kitchen knife. Better than nothing.", "damage": 3}, "dagger": { - "prototype": "knife", + "prototype_parent": "knife", "key": "Rusty dagger", "aliases": ["knife", "dagger"], "desc": "A double-edged dagger with a nicked edge and a wooden handle.", "hit": 0.25}, "sword": { - "prototype": "weapon", + "prototype_parent": "weapon", "key": "Rusty sword", "aliases": ["sword"], "desc": "A rusty shortsword. It has a leather-wrapped handle covered i food grease.", @@ -925,28 +925,28 @@ WEAPON_PROTOTYPES = { "damage": 5, "parry": 0.5}, "club": { - "prototype": "weapon", + "prototype_parent": "weapon", "key": "Club", "desc": "A heavy wooden club, little more than a heavy branch.", "hit": 0.4, "damage": 6, "parry": 0.2}, "axe": { - "prototype": "weapon", + "prototype_parent": "weapon", "key": "Axe", "desc": "A woodcutter's axe with a keen edge.", "hit": 0.4, "damage": 6, "parry": 0.2}, "ornate longsword": { - "prototype": "sword", + "prototype_parent": "sword", "key": "Ornate longsword", "desc": "A fine longsword with some swirling patterns on the handle.", "hit": 0.5, "magic": True, "damage": 5}, "warhammer": { - "prototype": "club", + "prototype_parent": "club", "key": "Silver Warhammer", "aliases": ["hammer", "warhammer", "war"], "desc": "A heavy war hammer with silver ornaments. This huge weapon causes massive damage - if you can hit.", @@ -954,21 +954,21 @@ WEAPON_PROTOTYPES = { "magic": True, "damage": 8}, "rune axe": { - "prototype": "axe", + "prototype_parent": "axe", "key": "Runeaxe", "aliases": ["axe"], "hit": 0.4, "magic": True, "damage": 6}, "thruning": { - "prototype": "ornate longsword", + "prototype_parent": "ornate longsword", "key": "Broadsword named Thruning", "desc": "This heavy bladed weapon is marked with the name 'Thruning'. It is very powerful in skilled hands.", "hit": 0.6, "parry": 0.6, "damage": 7}, "slayer waraxe": { - "prototype": "rune axe", + "prototype_parent": "rune axe", "key": "Slayer waraxe", "aliases": ["waraxe", "war", "slayer"], "desc": "A huge double-bladed axe marked with the runes for 'Slayer'." @@ -976,7 +976,7 @@ WEAPON_PROTOTYPES = { "hit": 0.7, "damage": 8}, "ghostblade": { - "prototype": "ornate longsword", + "prototype_parent": "ornate longsword", "key": "The Ghostblade", "aliases": ["blade", "ghost"], "desc": "This massive sword is large as you are tall, yet seems to weigh almost nothing." @@ -985,7 +985,7 @@ WEAPON_PROTOTYPES = { "parry": 0.8, "damage": 10}, "hawkblade": { - "prototype": "ghostblade", + "prototype_parent": "ghostblade", "key": "The Hawkblade", "aliases": ["hawk", "blade"], "desc": "The weapon of a long-dead heroine and a more civilized age," diff --git a/evennia/prototypes/prototypes.py b/evennia/prototypes/prototypes.py index 57087b133f..df9674b4e7 100644 --- a/evennia/prototypes/prototypes.py +++ b/evennia/prototypes/prototypes.py @@ -13,7 +13,7 @@ from evennia.objects.models import ObjectDB from evennia.utils.create import create_script from evennia.utils.utils import ( all_from_module, make_iter, is_iter, dbid_to_obj, callables_from_module, - get_all_typeclasses, to_str) + get_all_typeclasses, to_str, dbref) from evennia.locks.lockhandler import validate_lockstring, check_lockstring from evennia.utils import logger from evennia.utils import inlinefuncs @@ -91,6 +91,10 @@ def protfunc_parser(value, available_functions=None, testing=False, stacktrace=F """ if not isinstance(value, basestring): + try: + value = value.dbref + except AttributeError: + pass value = to_str(value, force_string=True) available_functions = _PROT_FUNCS if available_functions is None else available_functions @@ -577,7 +581,7 @@ def validate_prototype(prototype, protkey=None, protparents=None, for protstring in make_iter(prototype_parent): protstring = protstring.lower() if protkey is not None and protstring == protkey: - _flags['errors'].append("Protototype {} tries to parent itself.".format(protkey)) + _flags['errors'].append("Prototype {} tries to parent itself.".format(protkey)) protparent = protparents.get(protstring) if not protparent: _flags['errors'].append("Prototype {}'s prototype_parent '{}' was not found.".format( @@ -610,7 +614,7 @@ def validate_prototype(prototype, protkey=None, protparents=None, # make sure prototype_locks are set to defaults prototype_locks = [lstring.split(":", 1) - for lstring in prototype.get("prototype_locks", "").split(';')] + for lstring in prototype.get("prototype_locks", "").split(';') if ":" in lstring] locktypes = [tup[0].strip() for tup in prototype_locks] if "spawn" not in locktypes: prototype_locks.append(("spawn", "all()")) diff --git a/evennia/prototypes/tests.py b/evennia/prototypes/tests.py index 8932b368c1..221200672d 100644 --- a/evennia/prototypes/tests.py +++ b/evennia/prototypes/tests.py @@ -114,24 +114,41 @@ class TestUtils(EvenniaTest): self.assertEqual( pdiff, - {'aliases': 'REMOVE', - 'attrs': 'REPLACE', - 'home': 'KEEP', - 'key': 'UPDATE', - 'location': 'KEEP', - 'locks': 'KEEP', - 'new': 'UPDATE', - 'permissions': 'UPDATE', - 'prototype_desc': 'UPDATE', - 'prototype_key': 'UPDATE', - 'prototype_locks': 'KEEP', - 'prototype_tags': 'KEEP', - 'test': 'UPDATE', - 'typeclass': 'KEEP'}) + ({'aliases': 'REMOVE', + 'attrs': 'REPLACE', + 'home': 'KEEP', + 'key': 'UPDATE', + 'location': 'KEEP', + 'locks': 'KEEP', + 'new': 'UPDATE', + 'permissions': 'UPDATE', + 'prototype_desc': 'UPDATE', + 'prototype_key': 'UPDATE', + 'prototype_locks': 'KEEP', + 'prototype_tags': 'KEEP', + 'test': 'UPDATE', + 'typeclass': 'KEEP'}, + {'attrs': [('oldtest', 'to_remove', None, ['']), + ('test', 'testval', None, [''])], + 'prototype_locks': 'spawn:all();edit:all()', + 'prototype_key': Something, + 'locks': ['call:true()', 'control:perm(Developer)', + 'delete:perm(Admin)', 'edit:perm(Admin)', + 'examine:perm(Builder)', 'get:all()', + 'puppet:pperm(Developer)', 'tell:perm(Admin)', + 'view:all()'], + 'prototype_tags': [], + 'location': self.room1, + 'key': 'NewObj', + 'home': self.room1, + 'typeclass': 'evennia.objects.objects.DefaultObject', + 'prototype_desc': 'Built from NewObj', + 'aliases': 'foo'}) + ) # apply diff count = spawner.batch_update_objects_with_prototype( - old_prot, diff=pdiff, objects=[self.obj1]) + old_prot, diff=pdiff[0], objects=[self.obj1]) self.assertEqual(count, 1) new_prot = spawner.prototype_from_object(self.obj1) @@ -470,7 +487,7 @@ class TestOLCMenu(TestEvMenu): menutree = "evennia.prototypes.menus" startnode = "node_index" - debug_output = True + # debug_output = True expect_all_nodes = True expected_node_texts = { @@ -480,15 +497,37 @@ class TestOLCMenu(TestEvMenu): expected_tree = \ ['node_index', ['node_prototype_key', + ['node_index', + 'node_index', + 'node_validate_prototype', + ['node_index'], + 'node_index'], 'node_typeclass', - 'node_aliases', - 'node_attrs', - 'node_tags', - 'node_locks', - 'node_permissions', - 'node_location', - 'node_home', - 'node_destination', - 'node_prototype_desc', - 'node_prototype_tags', - 'node_prototype_locks']] + ['node_key', + ['node_typeclass', + 'node_key', + 'node_index', + 'node_validate_prototype', + 'node_validate_prototype'], + 'node_index', + 'node_index', + 'node_index', + 'node_validate_prototype', + 'node_validate_prototype'], + 'node_validate_prototype', + 'node_validate_prototype', + 'node_validate_prototype', + 'node_validate_prototype', + 'node_validate_prototype', + 'node_validate_prototype', + 'node_validate_prototype', + 'node_validate_prototype', + 'node_validate_prototype', + 'node_validate_prototype', + 'node_validate_prototype', + 'node_validate_prototype', + 'node_validate_prototype', + 'node_validate_prototype', + 'node_validate_prototype', + 'node_validate_prototype', + 'node_validate_prototype']] From 47eb1896d1e6f95cbbb3e4721799597b2e62660e Mon Sep 17 00:00:00 2001 From: Griatch Date: Sat, 21 Jul 2018 13:51:13 +0200 Subject: [PATCH 165/208] Remove old olc/ folder --- evennia/utils/olc/__init__.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 evennia/utils/olc/__init__.py diff --git a/evennia/utils/olc/__init__.py b/evennia/utils/olc/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 From 37e2e309efc16aa67f44c449adaa6b188b24a2d4 Mon Sep 17 00:00:00 2001 From: Griatch Date: Sat, 21 Jul 2018 14:13:58 +0200 Subject: [PATCH 166/208] Fix to redirect default at_first_login msg to right session --- evennia/accounts/accounts.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/evennia/accounts/accounts.py b/evennia/accounts/accounts.py index fe7693cce0..b32c3a31cb 100644 --- a/evennia/accounts/accounts.py +++ b/evennia/accounts/accounts.py @@ -777,7 +777,7 @@ class DefaultAccount(with_metaclass(TypeclassBase, AccountDB)): # any was deleted in the interim. self.db._playable_characters = [char for char in self.db._playable_characters if char] self.msg(self.at_look(target=self.db._playable_characters, - session=session)) + session=session), session=session) def at_failed_login(self, session, **kwargs): """ From 0e13f272b351d177c535029ff7db668fd042036c Mon Sep 17 00:00:00 2001 From: Griatch Date: Sat, 21 Jul 2018 19:06:15 +0200 Subject: [PATCH 167/208] Start improve OLC menu docs and help texts --- CHANGELOG.md | 9 ++ evennia/prototypes/menus.py | 180 +++++++++++++++++++++++++++---- evennia/prototypes/prototypes.py | 22 +++- evennia/utils/evmenu.py | 15 ++- evennia/utils/inlinefuncs.py | 6 +- 5 files changed, 203 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c1c4cf787..5ae990b85b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,15 @@ - The spawn command got the /save switch to save the defined prototype and its key. - The command spawn/menu will now start an OLC (OnLine Creation) menu to load/save/edit/spawn prototypes. +### EvMenu + +- Added `EvMenu.helptext_formatter(helptext)` to allow custom formatting of per-node help. +- Added `evennia.utils.evmenu.list_node` decorator for turning an EvMenu node into a multi-page listing. +- A `goto` option callable returning None (rather than the name of the next node) will now rerun the + current node instead of failing. +- Better error handling of in-node syntax errors. + + # Overviews diff --git a/evennia/prototypes/menus.py b/evennia/prototypes/menus.py index 1f2eb26a4f..1e775fbd77 100644 --- a/evennia/prototypes/menus.py +++ b/evennia/prototypes/menus.py @@ -8,6 +8,7 @@ import json from random import choice from django.conf import settings from evennia.utils.evmenu import EvMenu, list_node +from evennia.utils import evmore from evennia.utils.ansi import strip_ansi from evennia.utils import utils from evennia.prototypes import prototypes as protlib @@ -78,7 +79,9 @@ def _format_option_value(prop, required=False, prototype=None, cropper=None): out = ", ".join(str(pr) for pr in prop) if not out and required: out = "|rrequired" - return " ({}|n)".format(cropper(out) if cropper else utils.crop(out, _MENU_CROP_WIDTH)) + if out: + return " ({}|n)".format(cropper(out) if cropper else utils.crop(out, _MENU_CROP_WIDTH)) + return "" def _set_prototype_value(caller, field, value, parse=True): @@ -214,31 +217,75 @@ def _validate_prototype(prototype): return err, text -# Menu nodes +def _format_protfuncs(): + out = [] + sorted_funcs = [(key, func) for key, func in + sorted(protlib.PROT_FUNCS.items(), key=lambda tup: tup[0])] + for protfunc_name, protfunc in sorted_funcs: + out.append("- |c${name}|n - |W{docs}".format( + name=protfunc_name, + docs=utils.justify(protfunc.__doc__.strip(), align='l', indent=10).strip())) + return "\n ".join(out) + + +# Menu nodes ------------------------------ + + +# main index (start page) node + def node_index(caller): prototype = _get_menu_prototype(caller) - text = ( - "|c --- Prototype wizard --- |n\n\n" - "Define the |yproperties|n of the prototype. All prototype values can be " - "over-ridden at the time of spawning an instance of the prototype, but some are " - "required.\n\n'|wprototype-'-properties|n are not used in the prototype itself but are used " - "to organize and list prototypes. The 'prototype-key' uniquely identifies the prototype " - "and allows you to edit an existing prototype or save a new one for use by you or " - "others later.\n\n(make choice; q to abort. If unsure, start from 1.)") + text = """ + |c --- Prototype wizard --- |n + + A |cprototype|n is a 'template' for |wspawning|n an in-game entity. A field of the prototype + can be hard-coded or scripted using |w$protfuncs|n - for example to randomize the value + every time the prototype is used to spawn a new entity. + + The prototype fields named 'prototype_*' are not used to create the entity itself but for + organizing the template when saving it for you (and maybe others) to use later. + + Select prototype field to edit. If you are unsure, start from [|w1|n]. At any time you can + [|wV|n]alidate that the prototype works correctly and use it to [|wSP|n]awn a new entity. You + can also [|wSA|n]ve|n your work or [|wLO|n]oad an existing prototype to use as a base. Use + [|wL|n]ook to re-show a menu node. [|wQ|n]uit will always exit the menu and [|wH|n]elp will + show context-sensitive help. + """ + + helptxt = """ + |c- prototypes |n + + A prototype is really just a Python dictionary. When spawning, this dictionary is essentially + passed into `|wevennia.utils.create.create_object(**prototype)|n` to create a new object. By + using different prototypes you can customize instances of objects without having to do code + changes to their typeclass (something which requires code access). The classical example is + to spawn goblins with different names, looks, equipment and skill, each based on the same + `Goblin` typeclass. + + |c- $protfuncs |n + + Prototype-functions (protfuncs) allow for limited scripting within a prototype. These are + entered as a string $funcname(arg, arg, ...) and are evaluated |wat the time of spawning|n only. + They can also be nested for combined effects. + + {pfuncs} + """.format(pfuncs=_format_protfuncs()) + + text = (text, helptxt) options = [] options.append( {"desc": "|WPrototype-Key|n|n{}".format( _format_option_value("Key", "prototype_key" not in prototype, prototype, None)), "goto": "node_prototype_key"}) - for key in ('Typeclass', 'Prototype_parent', 'Key', 'Aliases', 'Attrs', 'Tags', 'Locks', + for key in ('Typeclass', 'Prototype-parent', 'Key', 'Aliases', 'Attrs', 'Tags', 'Locks', 'Permissions', 'Location', 'Home', 'Destination'): required = False cropper = None if key in ("Prototype-parent", "Typeclass"): - required = "prototype_parent" not in prototype and "typeclass" not in prototype + required = ("prototype_parent" not in prototype) and ("typeclass" not in prototype) if key == 'Typeclass': cropper = _path_cropper options.append( @@ -256,16 +303,18 @@ def node_index(caller): options.extend(( {"key": ("|wV|Walidate prototype", "validate", "v"), "goto": "node_validate_prototype"}, - {"key": ("|wS|Wave prototype", "save", "s"), + {"key": ("|wSA|Wve prototype", "save", "sa"), "goto": "node_prototype_save"}, {"key": ("|wSP|Wawn prototype", "spawn", "sp"), "goto": "node_prototype_spawn"}, - {"key": ("|wL|Woad prototype", "load", "l"), + {"key": ("|wLO|Wad prototype", "load", "lo"), "goto": "node_prototype_load"})) return text, options +# validate prototype (available as option from all nodes) + def node_validate_prototype(caller, raw_string, **kwargs): """General node to view and validate a protototype""" prototype = _get_menu_prototype(caller) @@ -273,11 +322,22 @@ def node_validate_prototype(caller, raw_string, **kwargs): _, text = _validate_prototype(prototype) + helptext = """ + The validator checks if the prototype's various values are on the expected form. It also test + any $protfuncs. + + """ + + text = (text, helptext) + options = _wizard_options(None, prev_node, None) return text, options +# prototype_key node + + def _check_prototype_key(caller, key): old_prototype = protlib.search_prototype(key) olc_new = _is_new_prototype(caller) @@ -303,22 +363,36 @@ def _check_prototype_key(caller, key): def node_prototype_key(caller): prototype = _get_menu_prototype(caller) - text = ["The prototype name, or |wMeta-Key|n, uniquely identifies the prototype. " - "It is used to find and use the prototype to spawn new entities. " - "It is not case sensitive."] + text = """ + The |cPrototype-Key|n uniquely identifies the prototype. It must be specified. It is used to + find and use the prototype to spawn new entities. It is not case sensitive. + + {current}""" + + helptext = """ + The prototype-key is not itself used to spawn the new object, but is only used for managing, + storing and loading the prototype. It must be globally unique, so existing keys will be + checked before a new key is accepted. If an existing key is picked, the existing prototype + will be loaded. + """ + old_key = prototype.get('prototype_key', None) if old_key: - text.append("Current key is '|w{key}|n'".format(key=old_key)) + text = text.format(current="Currently set to '|w{key}|n'".format(key=old_key)) else: - text.append("The key is currently unset.") - text.append("Enter text or make a choice (q for quit)") - text = "\n\n".join(text) + text = text.format(current="Currently |runset|n (required).") + options = _wizard_options("prototype_key", "index", "prototype_parent") options.append({"key": "_default", "goto": _check_prototype_key}) + + text = (text, helptext) return text, options +# prototype_parents node + + def _all_prototype_parents(caller): """Return prototype_key of all available prototypes for listing in menu""" return [prototype["prototype_key"] @@ -368,6 +442,8 @@ def node_prototype_parent(caller): return text, options +# typeclasses node + def _all_typeclasses(caller): """Get name of available typeclasses.""" return list(name for name in @@ -423,6 +499,9 @@ def node_typeclass(caller): return text, options +# key node + + def node_key(caller): prototype = _get_menu_prototype(caller) key = prototype.get("key") @@ -442,6 +521,9 @@ def node_key(caller): return text, options +# aliases node + + def node_aliases(caller): prototype = _get_menu_prototype(caller) aliases = prototype.get("aliases") @@ -462,6 +544,9 @@ def node_aliases(caller): return text, options +# attributes node + + def _caller_attrs(caller): prototype = _get_menu_prototype(caller) attrs = prototype.get("attrs", []) @@ -572,6 +657,9 @@ def node_attrs(caller): return text, options +# tags node + + def _caller_tags(caller): prototype = _get_menu_prototype(caller) tags = prototype.get("tags", []) @@ -659,6 +747,9 @@ def node_tags(caller): return text, options +# locks node + + def node_locks(caller): prototype = _get_menu_prototype(caller) locks = prototype.get("locks") @@ -679,6 +770,9 @@ def node_locks(caller): return text, options +# permissions node + + def node_permissions(caller): prototype = _get_menu_prototype(caller) permissions = prototype.get("permissions") @@ -699,6 +793,9 @@ def node_permissions(caller): return text, options +# location node + + def node_location(caller): prototype = _get_menu_prototype(caller) location = prototype.get("location") @@ -718,6 +815,9 @@ def node_location(caller): return text, options +# home node + + def node_home(caller): prototype = _get_menu_prototype(caller) home = prototype.get("home") @@ -737,6 +837,9 @@ def node_home(caller): return text, options +# destination node + + def node_destination(caller): prototype = _get_menu_prototype(caller) dest = prototype.get("dest") @@ -756,6 +859,9 @@ def node_destination(caller): return text, options +# prototype_desc node + + def node_prototype_desc(caller): prototype = _get_menu_prototype(caller) @@ -778,6 +884,9 @@ def node_prototype_desc(caller): return text, options +# prototype_tags node + + def node_prototype_tags(caller): prototype = _get_menu_prototype(caller) text = ["|wPrototype-Tags|n can be used to classify and find prototypes. " @@ -800,6 +909,9 @@ def node_prototype_tags(caller): return text, options +# prototype_locks node + + def node_prototype_locks(caller): prototype = _get_menu_prototype(caller) text = ["Set |wPrototype-Locks|n on the prototype. There are two valid lock types: " @@ -821,6 +933,9 @@ def node_prototype_locks(caller): return text, options +# update existing objects node + + def _update_spawned(caller, **kwargs): """update existing objects""" prototype = kwargs['prototype'] @@ -904,6 +1019,9 @@ def node_update_objects(caller, **kwargs): return text, options +# prototype save node + + def node_prototype_save(caller, **kwargs): """Save prototype to disk """ # these are only set if we selected 'yes' to save on a previous pass @@ -972,6 +1090,9 @@ def node_prototype_save(caller, **kwargs): return "\n".join(text), options +# spawning node + + def _spawn(caller, **kwargs): """Spawn prototype""" prototype = kwargs["prototype"].copy() @@ -1037,6 +1158,9 @@ def node_prototype_spawn(caller, **kwargs): return text, options +# prototype load node + + def _prototype_load_select(caller, prototype_key): matches = protlib.search_prototype(key=prototype_key) if matches: @@ -1052,12 +1176,15 @@ def _prototype_load_select(caller, prototype_key): @list_node(_all_prototype_parents, _prototype_load_select) def node_prototype_load(caller, **kwargs): text = ["Select a prototype to load. This will replace any currently edited prototype."] - options = _wizard_options("load", "save", "index") + options = _wizard_options("prototype_load", "prototype_save", "index") options.append({"key": "_default", "goto": _prototype_parent_examine}) return "\n".join(text), options +# EvMenu definition, formatting and access functions + + class OLCMenu(EvMenu): """ A custom EvMenu with a different formatting for the options. @@ -1086,6 +1213,15 @@ class OLCMenu(EvMenu): return "{}{}{}".format(olc_options, sep, other_options) + def helptext_formatter(self, helptext): + """ + Show help text + """ + return "|c --- Help ---|n\n" + helptext + + def display_helptext(self): + evmore.msg(self.caller, self.helptext, session=self._session) + def start_olc(caller, session=None, prototype=None): """ diff --git a/evennia/prototypes/prototypes.py b/evennia/prototypes/prototypes.py index df9674b4e7..011445b039 100644 --- a/evennia/prototypes/prototypes.py +++ b/evennia/prototypes/prototypes.py @@ -13,7 +13,7 @@ from evennia.objects.models import ObjectDB from evennia.utils.create import create_script from evennia.utils.utils import ( all_from_module, make_iter, is_iter, dbid_to_obj, callables_from_module, - get_all_typeclasses, to_str, dbref) + get_all_typeclasses, to_str, dbref, justify) from evennia.locks.lockhandler import validate_lockstring, check_lockstring from evennia.utils import logger from evennia.utils import inlinefuncs @@ -29,7 +29,7 @@ _PROTOTYPE_RESERVED_KEYS = _PROTOTYPE_META_NAMES + ( "permissions", "locks", "exec", "tags", "attrs") _PROTOTYPE_TAG_CATEGORY = "from_prototype" _PROTOTYPE_TAG_META_CATEGORY = "db_prototype" -_PROT_FUNCS = {} +PROT_FUNCS = {} _RE_DBREF = re.compile(r"(?".format(string) @@ -367,6 +368,7 @@ def parse_inlinefunc(string, strip=False, available_funcs=None, stacktrace=False try: # try to fetch the matching inlinefunc from storage stack.append(available_funcs[funcname]) + nvalid += 1 except KeyError: stack.append(available_funcs["nomatch"]) stack.append(funcname) @@ -393,9 +395,9 @@ def parse_inlinefunc(string, strip=False, available_funcs=None, stacktrace=False # this means not all inlinefuncs were complete return string - if _STACK_MAXSIZE > 0 and _STACK_MAXSIZE < len(stack): + if _STACK_MAXSIZE > 0 and _STACK_MAXSIZE < nvalid: # if stack is larger than limit, throw away parsing - return string + gdict["stackfull"](*args, **kwargs) + return string + available_funcs["stackfull"](*args, **kwargs) elif usecache: # cache the stack - we do this also if we don't check the cache above _PARSING_CACHE[string] = stack From 335109caf73827b72fee055ac745b4856f360c11 Mon Sep 17 00:00:00 2001 From: Griatch Date: Sat, 21 Jul 2018 22:43:36 +0200 Subject: [PATCH 168/208] Fix display error when telnet disabled --- evennia/server/portal/portal.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/evennia/server/portal/portal.py b/evennia/server/portal/portal.py index a720c73c71..6b15cde73a 100644 --- a/evennia/server/portal/portal.py +++ b/evennia/server/portal/portal.py @@ -343,7 +343,7 @@ if WEBSERVER_ENABLED: proxy_service = internet.TCPServer(proxyport, web_root, interface=interface) - proxy_service.setName('EvenniaWebProxy%s' % pstring) + proxy_service.setName('EvenniaWebProxy%s:%s' % (ifacestr, proxyport)) PORTAL.services.addService(proxy_service) INFO_DICT["webserver_proxy"].append("webserver-proxy%s: %s" % (ifacestr, proxyport)) INFO_DICT["webserver_internal"].append("webserver: %s" % serverport) From f21d625ae246ccebff7e6ee62ff7c44e5a859fa2 Mon Sep 17 00:00:00 2001 From: Johnathan Date: Mon, 23 Jul 2018 07:12:47 -0400 Subject: [PATCH 169/208] Python depends for telnet tls Addresses feature request in issue #1637 Installs py2-openssl as well as cryptography, pyasn1, and service_identity --- Dockerfile | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 27af6b2a3a..4b8411ecc2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -24,13 +24,14 @@ FROM alpine MAINTAINER www.evennia.com # install compilation environment -RUN apk update && apk add python py-pip python-dev py-setuptools gcc musl-dev jpeg-dev zlib-dev bash +RUN apk update && apk add python py-pip python-dev py-setuptools gcc musl-dev jpeg-dev zlib-dev bash py2-openssl # add the project source ADD . /usr/src/evennia # install dependencies RUN pip install -e /usr/src/evennia --trusted-host pypi.python.org +RUN pip install cryptography pyasn1 service_identity # add the game source when rebuilding a new docker image from inside # a game dir From 77a1d0a5e97f883a0ed9efb5ea5f659188aa91df Mon Sep 17 00:00:00 2001 From: Griatch Date: Mon, 23 Jul 2018 21:09:06 +0200 Subject: [PATCH 170/208] Rework AMP data packet format and batch-handling. Resolves #1635 --- evennia/server/portal/amp.py | 64 +++++++++++++++++++---------- evennia/server/portal/amp_server.py | 11 +++-- 2 files changed, 49 insertions(+), 26 deletions(-) diff --git a/evennia/server/portal/amp.py b/evennia/server/portal/amp.py index 4ff4732708..71a1bcba91 100644 --- a/evennia/server/portal/amp.py +++ b/evennia/server/portal/amp.py @@ -44,9 +44,10 @@ SSHUTD = chr(17) # server shutdown PSTATUS = chr(18) # ping server or portal status SRESET = chr(19) # server shutdown in reset mode +NUL = b'\0' +NULNUL = '\0\0' + AMP_MAXLEN = amp.MAX_VALUE_LENGTH # max allowed data length in AMP protocol (cannot be changed) -BATCH_RATE = 250 # max commands/sec before switching to batch-sending -BATCH_TIMEOUT = 0.5 # how often to poll to empty batch queue, in seconds # buffers _SENDBATCH = defaultdict(list) @@ -61,11 +62,15 @@ _HTTP_WARNING = """ HTTP/1.1 200 OK Content-Type: text/html - -This is Evennia's interal AMP port. It handles communication -between Evennia's different processes.

This port should NOT be -publicly visible.

-""".strip() + + + This is Evennia's internal AMP port. It handles communication + between Evennia's different processes. +

+

This port should NOT be publicly visible.

+

+ +""".strip() # Helper functions for pickling. @@ -107,43 +112,45 @@ class Compressed(amp.String): def fromBox(self, name, strings, objects, proto): """ - Converts from box representation to python. We - group very long data into batches. + Converts from box string representation to python. We read back too-long batched data and + put it back together here. + """ value = StringIO() - value.write(strings.get(name)) + value.write(self.fromStringProto(strings.get(name), proto)) for counter in count(2): # count from 2 upwards chunk = strings.get("%s.%d" % (name, counter)) if chunk is None: break - value.write(chunk) + value.write(self.fromStringProto(chunk, proto)) objects[name] = value.getvalue() def toBox(self, name, strings, objects, proto): """ - Convert from data to box. We handled too-long - batched data and put it together here. + Convert from python object to string box representation. + we break up too-long data snippets into multiple batches here. + """ value = StringIO(objects[name]) - strings[name] = value.read(AMP_MAXLEN) + strings[name] = self.toStringProto(value.read(AMP_MAXLEN), proto) for counter in count(2): chunk = value.read(AMP_MAXLEN) if not chunk: break - strings["%s.%d" % (name, counter)] = chunk + strings["%s.%d" % (name, counter)] = self.toStringProto(chunk, proto) def toString(self, inObject): """ - Convert to send on the wire, with compression. + Convert to send as a string on the wire, with compression. """ - return zlib.compress(inObject, 9) + return zlib.compress(super(Compressed, self).toString(inObject), 9) def fromString(self, inString): """ - Convert (decompress) from the wire to Python. + Convert (decompress) from the string-representation on the wire to Python. """ - return zlib.decompress(inString) + return super(Compressed, self).fromString(zlib.decompress(inString)) class MsgLauncher2Portal(amp.Command): @@ -261,16 +268,29 @@ class AMPMultiConnectionProtocol(amp.AMP): self.send_reset_time = time.time() self.send_mode = True self.send_task = None + self.multibatches = 0 def dataReceived(self, data): """ Handle non-AMP messages, such as HTTP communication. """ - if data[0] != b'\0': + if data[0] == NUL: + # an AMP communication + if data[-2:] != NULNUL: + # an incomplete AMP box means more batches are forthcoming. + self.multibatches += 1 + super(AMPMultiConnectionProtocol, self).dataReceived(data) + elif self.multibatches: + # invalid AMP, but we have a pending multi-batch that is not yet complete + if data[-2:] == NULNUL: + # end of existing multibatch + self.multibatches = max(0, self.multibatches - 1) + super(AMPMultiConnectionProtocol, self).dataReceived(data) + else: + # not an AMP communication, return warning self.transport.write(_HTTP_WARNING) self.transport.loseConnection() - else: - super(AMPMultiConnectionProtocol, self).dataReceived(data) + print("HTML received: %s" % data) def makeConnection(self, transport): """ diff --git a/evennia/server/portal/amp_server.py b/evennia/server/portal/amp_server.py index c550a648c3..38e39fb464 100644 --- a/evennia/server/portal/amp_server.py +++ b/evennia/server/portal/amp_server.py @@ -356,10 +356,13 @@ class AMPServerProtocol(amp.AMPMultiConnectionProtocol): packed_data (str): Pickled data (sessid, kwargs) coming over the wire. """ - sessid, kwargs = self.data_in(packed_data) - session = self.factory.portal.sessions.get(sessid, None) - if session: - self.factory.portal.sessions.data_out(session, **kwargs) + try: + sessid, kwargs = self.data_in(packed_data) + session = self.factory.portal.sessions.get(sessid, None) + if session: + self.factory.portal.sessions.data_out(session, **kwargs) + except Exception: + logger.log_trace("packed_data len {}".format(len(packed_data))) return {} @amp.AdminServer2Portal.responder From 058f35650a189eef6794237f4c52e3994e1f62ba Mon Sep 17 00:00:00 2001 From: Griatch Date: Mon, 23 Jul 2018 23:12:11 +0200 Subject: [PATCH 171/208] Add more in-menu docs --- evennia/prototypes/menus.py | 133 +++++++++++++++++++++++++----------- 1 file changed, 95 insertions(+), 38 deletions(-) diff --git a/evennia/prototypes/menus.py b/evennia/prototypes/menus.py index 1e775fbd77..e70d5d87ae 100644 --- a/evennia/prototypes/menus.py +++ b/evennia/prototypes/menus.py @@ -117,8 +117,6 @@ def _set_property(caller, raw_string, **kwargs): processor = kwargs.get("processor", None) next_node = kwargs.get("next_node", "node_index") - propname_low = prop.strip().lower() - if callable(processor): try: value = processor(raw_string) @@ -134,13 +132,6 @@ def _set_property(caller, raw_string, **kwargs): return next_node prototype = _set_prototype_value(caller, prop, value) - - # typeclass and prototype_parent can't co-exist - if propname_low == "typeclass": - prototype.pop("prototype_parent", None) - if propname_low == "prototype_parent": - prototype.pop("typeclass", None) - caller.ndb._menutree.olc_prototype = prototype try: @@ -253,7 +244,6 @@ def node_index(caller): [|wL|n]ook to re-show a menu node. [|wQ|n]uit will always exit the menu and [|wH|n]elp will show context-sensitive help. """ - helptxt = """ |c- prototypes |n @@ -323,7 +313,7 @@ def node_validate_prototype(caller, raw_string, **kwargs): _, text = _validate_prototype(prototype) helptext = """ - The validator checks if the prototype's various values are on the expected form. It also test + The validator checks if the prototype's various values are on the expected form. It also tests any $protfuncs. """ @@ -364,16 +354,15 @@ def _check_prototype_key(caller, key): def node_prototype_key(caller): prototype = _get_menu_prototype(caller) text = """ - The |cPrototype-Key|n uniquely identifies the prototype. It must be specified. It is used to + The |cPrototype-Key|n uniquely identifies the prototype and is |wmandatory|n. It is used to find and use the prototype to spawn new entities. It is not case sensitive. {current}""" - helptext = """ - The prototype-key is not itself used to spawn the new object, but is only used for managing, - storing and loading the prototype. It must be globally unique, so existing keys will be - checked before a new key is accepted. If an existing key is picked, the existing prototype - will be loaded. + The prototype-key is not itself used when spawnng the new object, but is only used for + managing, storing and loading the prototype. It must be globally unique, so existing keys + will be checked before a new key is accepted. If an existing key is picked, the existing + prototype will be loaded. """ old_key = prototype.get('prototype_key', None) @@ -423,18 +412,36 @@ def node_prototype_parent(caller): prot_parent_key = prototype.get('prototype') - text = ["Set the prototype's |yParent Prototype|n. If this is unset, Typeclass will be used."] + text = """ + The |cPrototype Parent|n allows you to |winherit|n prototype values from another named + prototype (given as that prototype's |wprototype_key|). If not changing these values in the + current prototype, the parent's value will be used. Pick the available prototypes below. + + Note that somewhere in the prototype's parentage, a |ctypeclass|n must be specified. If no + parent is given, this prototype must define the typeclass (next menu node). + + {current} + """ + helptext = """ + Prototypes can inherit from one another. Changes in the child replace any values set in a + parent. The |wtypeclass|n key must exist |wsomewhere|n in the parent chain for the + prototype to be valid. + """ + if prot_parent_key: prot_parent = protlib.search_prototype(prot_parent_key) if prot_parent: - text.append( - "Current parent prototype is {}:\n{}".format(protlib.prototype_to_str(prot_parent))) + text.format( + current="Current parent prototype is {}:\n{}".format( + protlib.prototype_to_str(prot_parent))) else: - text.append("Current parent prototype |r{prototype}|n " + text.format( + current="Current parent prototype |r{prototype}|n " "does not appear to exist.".format(prot_parent_key)) else: - text.append("Parent prototype is not set") - text = "\n\n".join(text) + text.format(current="Parent prototype is not set") + text = (text, helptext) + options = _wizard_options("prototype_parent", "prototype_key", "typeclass", color="|W") options.append({"key": "_default", "goto": _prototype_parent_examine}) @@ -477,7 +484,7 @@ def _typeclass_examine(caller, typeclass_path): def _typeclass_select(caller, typeclass): """Select typeclass from list and add it to prototype. Return next node to go to.""" ret = _set_property(caller, typeclass, prop='typeclass', processor=str, next_node="node_key") - caller.msg("Selected typeclass |y{}|n. Removed any set prototype parent.".format(typeclass)) + caller.msg("Selected typeclass |y{}|n.".format(typeclass)) return ret @@ -486,13 +493,32 @@ def node_typeclass(caller): prototype = _get_menu_prototype(caller) typeclass = prototype.get("typeclass") - text = ["Set the typeclass's parent |yTypeclass|n."] + text = """ + The |cTypeclass|n defines what 'type' of object this is - the actual working code to use. + + All spawned objects must have a typeclass. If not given here, the typeclass must be set in + one of the prototype's |cparents|n. + + {current} + """ + helptext = """ + A |nTypeclass|n is specified by the actual python-path to the class definition in the + Evennia code structure. + + Which |cAttributes|n, |cLocks|n and other properties have special + effects or expects certain values depend greatly on the code in play. + """ + if typeclass: - text.append("Current typeclass is |y{typeclass}|n.".format(typeclass=typeclass)) + text.format( + current="Current typeclass is |y{typeclass}|n.".format(typeclass=typeclass)) else: - text.append("Using default typeclass {typeclass}.".format( - typeclass=settings.BASE_OBJECT_TYPECLASS)) - text = "\n\n".join(text) + text.format( + current="Using default typeclass {typeclass}.".format( + typeclass=settings.BASE_OBJECT_TYPECLASS)) + + text = (text, helptext) + options = _wizard_options("typeclass", "prototype_parent", "key", color="|W") options.append({"key": "_default", "goto": _typeclass_examine}) @@ -506,12 +532,27 @@ def node_key(caller): prototype = _get_menu_prototype(caller) key = prototype.get("key") - text = ["Set the prototype's name (|yKey|n.) This will retain case sensitivity."] + text = """ + The |cKey|n is the given name of the object to spawn. This will retain the given case. + + {current} + """ + helptext = """ + The key should often not be identical for every spawned object. Using a randomising + $protfunc can be used, for example |c$choice(Alan, Tom, John)|n will give one of the three + names every time an object of this prototype is spawned. + + |c$protfuncs|n + {pfuncs} + """.format(pfuncs=_format_protfuncs()) + if key: - text.append("Current key value is '|y{key}|n'.".format(key=key)) + text.format(current="Current key is '{key}'.".format(key=key)) else: - text.append("Key is currently unset.") - text = "\n\n".join(text) + text.format(current="The key is currently unset.") + + text = (text, helptext) + options = _wizard_options("key", "typeclass", "aliases") options.append({"key": "_default", "goto": (_set_property, @@ -528,13 +569,29 @@ def node_aliases(caller): prototype = _get_menu_prototype(caller) aliases = prototype.get("aliases") - text = ["Set the prototype's |yAliases|n. Separate multiple aliases with commas. " - "they'll retain case sensitivity."] + text = """ + |cAliases|n are alternative ways to address an object, next to its |cKey|n. Aliases are not + case sensitive. + + Add multiple aliases separating with commas. + + {current} + """ + helptext = """ + Aliases are fixed alternative identifiers and are stored with the new object. + + |c$protfuncs|n + + {pfuncs} + """.format(pfuncs=_format_protfuncs()) + if aliases: - text.append("Current aliases are '|y{aliases}|n'.".format(aliases=aliases)) + text.format(current="Current aliases are '|c{aliases}|n'.".format(aliases=aliases)) else: - text.append("No aliases are set.") - text = "\n\n".join(text) + text.format(current="No aliases are set.") + + text = (text, helptext) + options = _wizard_options("aliases", "key", "attrs") options.append({"key": "_default", "goto": (_set_property, From 78e84200c59d81aa4d2ff86f089495dfce4221f6 Mon Sep 17 00:00:00 2001 From: "Aris (Karim) Merchant" Date: Mon, 23 Jul 2018 16:56:57 -0700 Subject: [PATCH 172/208] Add procps dependency to Dockerfile Certain evennia commands, such as the server command, rely upon ps to run correctly. Unfortunately, alpine uses the BusyBox ps command, which is somewhat idiosyncratic in its option handling. Adding the procps dependency installs a more standard ps command, allowing server maintenance commands to work correctly. This fixes #1635. For further background, see gliderlabs/docker-alpine#173. --- Dockerfile | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/Dockerfile b/Dockerfile index 27af6b2a3a..97f410135f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,11 +5,11 @@ # install `docker` (http://docker.com) # # Usage: -# cd to a folder where you want your game data to be (or where it already is). +# cd to a folder where you want your game data to be (or where it already is). # # docker run -it -p 4000:4000 -p 4001:4001 -p 4005:4005 -v $PWD:/usr/src/game evennia/evennia -# -# (If your OS does not support $PWD, replace it with the full path to your current +# +# (If your OS does not support $PWD, replace it with the full path to your current # folder). # # You will end up in a shell where the `evennia` command is available. From here you @@ -24,7 +24,8 @@ FROM alpine MAINTAINER www.evennia.com # install compilation environment -RUN apk update && apk add python py-pip python-dev py-setuptools gcc musl-dev jpeg-dev zlib-dev bash +RUN apk update && apk add python py-pip python-dev py-setuptools gcc \ +musl-dev jpeg-dev zlib-dev bash procps # add the project source ADD . /usr/src/evennia @@ -33,7 +34,7 @@ ADD . /usr/src/evennia RUN pip install -e /usr/src/evennia --trusted-host pypi.python.org # add the game source when rebuilding a new docker image from inside -# a game dir +# a game dir ONBUILD ADD . /usr/src/game # make the game source hierarchy persistent with a named volume. From 89ffa84c01585d018441d88dd30a160db5097892 Mon Sep 17 00:00:00 2001 From: Griatch Date: Tue, 24 Jul 2018 20:48:54 +0200 Subject: [PATCH 173/208] List lockfuncs in menu, more elaborate doc strings --- evennia/locks/lockhandler.py | 13 ++ evennia/prototypes/menus.py | 327 +++++++++++++++++++++++++++++------ 2 files changed, 284 insertions(+), 56 deletions(-) diff --git a/evennia/locks/lockhandler.py b/evennia/locks/lockhandler.py index 9e27ca2fad..19bfbec707 100644 --- a/evennia/locks/lockhandler.py +++ b/evennia/locks/lockhandler.py @@ -663,6 +663,19 @@ def validate_lockstring(lockstring): return _LOCK_HANDLER.validate(lockstring) +def get_all_lockfuncs(): + """ + Get a dict of available lock funcs. + + Returns: + lockfuncs (dict): Mapping {lockfuncname:func}. + + """ + if not _LOCKFUNCS: + _cache_lockfuncs() + return _LOCKFUNCS + + def _test(): # testing diff --git a/evennia/prototypes/menus.py b/evennia/prototypes/menus.py index e70d5d87ae..182a8b63f4 100644 --- a/evennia/prototypes/menus.py +++ b/evennia/prototypes/menus.py @@ -11,6 +11,7 @@ from evennia.utils.evmenu import EvMenu, list_node from evennia.utils import evmore from evennia.utils.ansi import strip_ansi from evennia.utils import utils +from evennia.locks.lockhandler import get_all_lockfuncs from evennia.prototypes import prototypes as protlib from evennia.prototypes import spawner @@ -219,6 +220,16 @@ def _format_protfuncs(): return "\n ".join(out) +def _format_lockfuncs(): + out = [] + sorted_funcs = [(key, func) for key, func in + sorted(get_all_lockfuncs(), key=lambda tup: tup[0])] + for lockfunc_name, lockfunc in sorted_funcs: + out.append("- |c${name}|n - |W{docs}".format( + name=lockfunc_name, + docs=utils.justify(lockfunc.__doc__.strip(), align='l', indent=10).strip())) + + # Menu nodes ------------------------------ @@ -694,17 +705,37 @@ def node_attrs(caller): prot = _get_menu_prototype(caller) attrs = prot.get("attrs") - text = ["Set the prototype's |yAttributes|n. Enter attributes on one of these forms:\n" - " attrname=value\n attrname;category=value\n attrname;category;lockstring=value\n" - "To give an attribute without a category but with a lockstring, leave that spot empty " - "(attrname;;lockstring=value)." - "Separate multiple attrs with commas. Use quotes to escape inputs with commas and " - "semi-colon."] + text = """ + |cAttributes|n are custom properties of the object. Enter attributes on one of these forms: + + attrname=value + attrname;category=value + attrname;category;lockstring=value + + To give an attribute without a category but with a lockstring, leave that spot empty + (attrname;;lockstring=value). Attribute values can have embedded $protfuncs. + + {current} + """ + helptext = """ + Most commonly, Attributes don't need any categories or locks. If using locks, the lock-types + 'attredit', 'attrread' are used to limiting editing and viewing of the Attribute. Putting + the lock-type `attrcreate` in the |clocks|n prototype key can be used to restrict builders + to add new Attributes. + + |c$protfuncs + + {pfuncs} + """.format(pfuncs=_format_protfuncs()) + if attrs: - text.append("Current attrs are '|y{attrs}|n'.".format(attrs=attrs)) + text.format(current="Current attrs {attrs}.".format( + attrs=attrs)) else: - text.append("No attrs are set.") - text = "\n\n".join(text) + text.format(current="No attrs are set.") + + text = (text, helptext) + options = _wizard_options("attrs", "aliases", "tags") options.append({"key": "_default", "goto": (_set_property, @@ -797,9 +828,24 @@ def _edit_tag(caller, old_tag, new_tag, **kwargs): @list_node(_caller_tags) def node_tags(caller): - text = ("Set the prototype's |yTags|n. Enter tags on one of the following forms:\n" - " tag\n tag;category\n tag;category;data\n" - "Note that 'data' is not commonly used.") + text = """ + |cTags|n are used to group objects so they can quickly be found later. Enter tags on one of + the following forms: + tagname + tagname;category + tagname;category;data + """ + helptext = """ + Tags are shared between all objects with that tag. So the 'data' field (which is not + commonly used) can only hold eventual info about the Tag itself, not about the individual + object on which it sits. + + All objects created with this prototype will automatically get assigned a tag named the same + as the |cprototype_key|n and with a category "{tag_category}". This allows the spawner to + optionally update previously spawned objects when their prototype changes. + """.format(protlib._PROTOTYPE_TAG_CATEGORY) + + text = (text, helptext) options = _wizard_options("tags", "attrs", "locks") return text, options @@ -811,13 +857,39 @@ def node_locks(caller): prototype = _get_menu_prototype(caller) locks = prototype.get("locks") - text = ["Set the prototype's |yLock string|n. Separate multiple locks with semi-colons. " - "Will retain case sensitivity."] + text = """ + The |cLock string|n defines limitations for accessing various properties of the object once + it's spawned. The string should be on one of the following forms: + + locktype:[NOT] lockfunc(args) + locktype: [NOT] lockfunc(args) [AND|OR|NOT] lockfunc(args) [AND|OR|NOT] ... + + Separate multiple lockstrings by semicolons (;). + + {current} + """ + helptext = """ + Here is an example of a lock string constisting of two locks: + + edit:false();call:tag(Foo) OR perm(Builder) + + Above locks limit two things, 'edit' and 'call'. Which lock types are actually checked + depend on the typeclass of the object being spawned. Here 'edit' is never allowed by anyone + while 'call' is allowed to all accessors with a |ctag|n 'Foo' OR which has the + |cPermission|n 'Builder'. + + |c$lockfuncs|n + + {lfuncs} + """.format(lfuncs=_format_lockfuncs()) + if locks: - text.append("Current locks are '|y{locks}|n'.".format(locks=locks)) + text.format(current="Current locks are '|y{locks}|n'.".format(locks=locks)) else: - text.append("No locks are set.") - text = "\n\n".join(text) + text.format(current="No locks are set.") + + text = (text, helptext) + options = _wizard_options("locks", "tags", "permissions") options.append({"key": "_default", "goto": (_set_property, @@ -834,13 +906,32 @@ def node_permissions(caller): prototype = _get_menu_prototype(caller) permissions = prototype.get("permissions") - text = ["Set the prototype's |yPermissions|n. Separate multiple permissions with commas. " - "Will retain case sensitivity."] + text = """ + |cPermissions|n are simple strings used to grant access to this object. A permission is used + when a |clock|n is checked that contains the |wperm|n or |wpperm|n lock functions. + + {current} + """ + helptext = """ + Any string can act as a permission as long as a lock is set to look for it. Depending on the + lock, having a permission could even be negative (i.e. the lock is only passed if you + |wdon't|n have the 'permission'). The most common permissions are the hierarchical + permissions: + + {permissions}. + + For example, a |clock|n string like "edit:perm(Builder)" will grant access to accessors + having the |cpermission|n "Builder" or higher. + """.format(settings.PERMISSION_HIERARCHY) + if permissions: - text.append("Current permissions are '|y{permissions}|n'.".format(permissions=permissions)) + text.format(current="Current permissions are {permissions}.".format( + permissions=permissions)) else: - text.append("No permissions are set.") - text = "\n\n".join(text) + text.format(current="No permissions are set.") + + text = (text, helptext) + options = _wizard_options("permissions", "destination", "location") options.append({"key": "_default", "goto": (_set_property, @@ -857,12 +948,28 @@ def node_location(caller): prototype = _get_menu_prototype(caller) location = prototype.get("location") - text = ["Set the prototype's |yLocation|n"] + text = """ + The |cLocation|n of this object in the world. If not given, the object will spawn + in the inventory of |c{caller}|n instead. + + {current} + """.format(caller=caller.key) + helptext = """ + You get the most control by not specifying the location - you can then teleport the spawned + objects as needed later. Setting the location may be useful for quickly populating a given + location. One could also consider randomizing the location using a $protfunc. + + |c$protfuncs|n + {pfuncs} + """.format(pfuncs=_format_protfuncs) + if location: - text.append("Current location is |y{location}|n.".format(location=location)) + text.format(current="Current location is {location}.".format(location=location)) else: - text.append("Default location is {}'s inventory.".format(caller)) - text = "\n\n".join(text) + text.format(current="Default location is {}'s inventory.".format(caller)) + + text = (text, helptext) + options = _wizard_options("location", "permissions", "home") options.append({"key": "_default", "goto": (_set_property, @@ -879,12 +986,28 @@ def node_home(caller): prototype = _get_menu_prototype(caller) home = prototype.get("home") - text = ["Set the prototype's |yHome location|n"] + text = """ + The |cHome|n location of an object is often only used as a backup - this is where the object + will be moved to if its location is deleted. The home location can also be used as an actual + home for characters to quickly move back to. If unset, the global home default will be used. + + {current} + """ + helptext = """ + The location can be specified as as #dbref but can also be explicitly searched for using + $obj(name). + + The home location is often not used except as a backup. It should never be unset. + """ + if home: - text.append("Current home location is |y{home}|n.".format(home=home)) + text.format(current="Current home location is {home}.".format(home=home)) else: - text.append("Default home location (|y{home}|n) used.".format(home=settings.DEFAULT_HOME)) - text = "\n\n".join(text) + text.format( + current="Default home location ({home}) used.".format(home=settings.DEFAULT_HOME)) + + text = (text, helptext) + options = _wizard_options("home", "aliases", "destination") options.append({"key": "_default", "goto": (_set_property, @@ -901,12 +1024,24 @@ def node_destination(caller): prototype = _get_menu_prototype(caller) dest = prototype.get("dest") - text = ["Set the prototype's |yDestination|n. This is usually only used for Exits."] + text = """ + The object's |cDestination|n is usually only set for Exit-like objects and designates where + the exit 'leads to'. It's usually unset for all other types of objects. + + {current} + """ + helptext = """ + The destination can be given as a #dbref but can also be explicitly searched for using + $obj(name). + """ + if dest: - text.append("Current destination is |y{dest}|n.".format(dest=dest)) + text.format(current="Current destination is {dest}.".format(dest=dest)) else: - text.append("No destination is set (default).") - text = "\n\n".join(text) + text.format("No destination is set (default).") + + text = (text, helptext) + options = _wizard_options("destination", "home", "prototype_desc") options.append({"key": "_default", "goto": (_set_property, @@ -922,15 +1057,25 @@ def node_destination(caller): def node_prototype_desc(caller): prototype = _get_menu_prototype(caller) - text = ["The |wPrototype-Description|n briefly describes the prototype for " - "viewing in listings."] desc = prototype.get("prototype_desc", None) + text = """ + The |cPrototype-Description|n optionally briefly describes the prototype when it's viewed in + listings. + + {current} + """ + helptext = """ + Giving a brief description helps you and others to locate the prototype for use later. + """ + if desc: - text.append("The current meta desc is:\n\"|w{desc}|n\"".format(desc=desc)) + text.format(current="The current meta desc is:\n\"|w{desc}|n\"".format(desc=desc)) else: - text.append("Description is currently unset.") - text = "\n\n".join(text) + text.format(current="Prototype-Description is currently unset.") + + text = (text, helptext) + options = _wizard_options("prototype_desc", "prototype_key", "prototype_tags") options.append({"key": "_default", "goto": (_set_property, @@ -946,16 +1091,25 @@ def node_prototype_desc(caller): def node_prototype_tags(caller): prototype = _get_menu_prototype(caller) - text = ["|wPrototype-Tags|n can be used to classify and find prototypes. " - "Tags are case-insensitive. " - "Separate multiple by tags by commas."] + text = """ + |cPrototype-Tags|n can be used to classify and find prototypes in listings Tag names are not + case-sensitive and can have not have a custom category. Separate multiple tags by commas. + """ + helptext = """ + Using prototype-tags is a good way to organize and group large numbers of prototypes by + genre, type etc. Under the hood, prototypes' tags will all be stored with the category + '{tagmetacategory}'. + """.format(tagmetacategory=protlib._PROTOTYPE_TAG_META_CATEGORY) + tags = prototype.get('prototype_tags', []) if tags: - text.append("The current tags are:\n|w{tags}|n".format(tags=tags)) + text.format(current="The current tags are:\n|w{tags}|n".format(tags=tags)) else: - text.append("No tags are currently set.") - text = "\n\n".join(text) + text.format(current="No tags are currently set.") + + text = (text, helptext) + options = _wizard_options("prototype_tags", "prototype_desc", "prototype_locks") options.append({"key": "_default", "goto": (_set_property, @@ -971,16 +1125,35 @@ def node_prototype_tags(caller): def node_prototype_locks(caller): prototype = _get_menu_prototype(caller) - text = ["Set |wPrototype-Locks|n on the prototype. There are two valid lock types: " - "'edit' (who can edit the prototype) and 'spawn' (who can spawn new objects with this " - "prototype)\n(If you are unsure, leave as default.)"] locks = prototype.get('prototype_locks', '') + + text = """ + |cPrototype-Locks|n are used to limit access to this prototype when someone else is trying + to access it. By default any prototype can be edited only by the creator and by Admins while + they can be used by anyone with access to the spawn command. There are two valid lock types + the prototype access tools look for: + + - 'edit': Who can edit the prototype. + - 'spawn': Who can spawn new objects with this prototype. + + If unsure, leave as default. + + {current} + """ + helptext = """ + Prototype locks can be used when there are different tiers of builders or for developers to + produce 'base prototypes' only meant for builders to inherit and expand on rather than + change. + """ + if locks: - text.append("Current lock is |w'{lockstring}'|n".format(lockstring=locks)) + text.format(current="Current lock is |w'{lockstring}'|n".format(lockstring=locks)) else: - text.append("Lock unset - if not changed the default lockstring will be set as\n" - " |w'spawn:all(); edit:id({dbref}) or perm(Admin)'|n".format(dbref=caller.id)) - text = "\n\n".join(text) + text.format( + current="Default lock set: |w'spawn:all(); edit:id({dbref}) or perm(Admin)'|n".format(dbref=caller.id)) + + text = (text, helptext) + options = _wizard_options("prototype_locks", "prototype_tags", "index") options.append({"key": "_default", "goto": (_set_property, @@ -1039,7 +1212,7 @@ def node_update_objects(caller, **kwargs): obj = choice(update_objects) diff, obj_prototype = spawner.prototype_diff_from_object(prototype, obj) - text = ["Suggested changes to {} objects".format(len(update_objects)), + text = ["Suggested changes to {} objects. ".format(len(update_objects)), "Showing random example obj to change: {name} (#{dbref}))\n".format(obj.key, obj.dbref)] options = [] io = 0 @@ -1073,6 +1246,17 @@ def node_update_objects(caller, **kwargs): {"key": "|wb|rack ({})".format(back_node[5:], 'b'), "goto": back_node}]) + helptext = """ + Be careful with this operation! The upgrade mechanism will try to automatically estimate + what changes need to be applied. But the estimate is |wonly based on the analysis of one + randomly selected object|n among all objects spawned by this prototype. If that object + happens to be unusual in some way the estimate will be off and may lead to unexpected + results for other objects. Always test your objects carefully after an upgrade and + consider being conservative (switch to KEEP) or even do the update manually if you are + unsure that the results will be acceptable. """ + + text = (text, helptext) + return text, options @@ -1144,7 +1328,17 @@ def node_prototype_save(caller, **kwargs): "goto": ("node_prototype_save", {"accept": True, "prototype": prototype})}) - return "\n".join(text), options + helptext = """ + Saving the prototype makes it available for use later. It can also be used to inherit from, + by name. Depending on |cprototype-locks|n it also makes the prototype usable and/or + editable by others. Consider setting good |cPrototype-tags|n and to give a useful, brief + |cPrototype-desc|n to make the prototype easy to find later. + + """ + + text = (text, helptext) + + return text, options # spawning node @@ -1212,6 +1406,16 @@ def node_prototype_spawn(caller, **kwargs): dict(prototype=prototype, opjects=spawned_objects, back_node="node_prototype_spawn"))}) options.extend(_wizard_options("prototype_spawn", "prototype_save", "index")) + + helptext = """ + Spawning is the act of instantiating a prototype into an actual object. As a new object is + spawned, every $protfunc in the prototype is called anew. Since this is a common thing to + do, you may also temporarily change the |clocation|n of this prototype to bypass whatever + value is set in the prototype. + + """ + text = (text, helptext) + return text, options @@ -1232,11 +1436,22 @@ def _prototype_load_select(caller, prototype_key): @list_node(_all_prototype_parents, _prototype_load_select) def node_prototype_load(caller, **kwargs): - text = ["Select a prototype to load. This will replace any currently edited prototype."] + """Load prototype""" + + text = """ + Select a prototype to load. This will replace any prototype currently being edited! + """ + helptext = """ + Loading a prototype will load it and return you to the main index. It can be a good idea to + examine the prototype before loading it. + """ + + text = (text, helptext) + options = _wizard_options("prototype_load", "prototype_save", "index") options.append({"key": "_default", "goto": _prototype_parent_examine}) - return "\n".join(text), options + return text, options # EvMenu definition, formatting and access functions From 423023419b54512aa5656058a63f2fabb8f77942 Mon Sep 17 00:00:00 2001 From: Griatch Date: Tue, 24 Jul 2018 20:54:21 +0200 Subject: [PATCH 174/208] Fix unit tests --- evennia/utils/tests/test_evmenu.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/evennia/utils/tests/test_evmenu.py b/evennia/utils/tests/test_evmenu.py index d3ee14a74f..a6959c0509 100644 --- a/evennia/utils/tests/test_evmenu.py +++ b/evennia/utils/tests/test_evmenu.py @@ -82,6 +82,8 @@ class TestEvMenu(TestCase): self.assertIsNotNone( bool(node_text), "node: {}: node-text is None, which was not expected.".format(nodename)) + if isinstance(node_text, tuple): + node_text, helptext = node_text node_text = ansi.strip_ansi(node_text.strip()) self.assertTrue( node_text.startswith(compare_text), From c82eabf6edbc552b5f030137b468eb8c5a3b9ba4 Mon Sep 17 00:00:00 2001 From: Griatch Date: Tue, 24 Jul 2018 21:47:54 +0200 Subject: [PATCH 175/208] Prepare for flattening prototype display --- evennia/commands/default/building.py | 2 +- evennia/prototypes/menus.py | 63 +++++++++++++++------------- evennia/prototypes/spawner.py | 23 ++++++++-- 3 files changed, 54 insertions(+), 34 deletions(-) diff --git a/evennia/commands/default/building.py b/evennia/commands/default/building.py index cdcbadc103..bd4fb5e188 100644 --- a/evennia/commands/default/building.py +++ b/evennia/commands/default/building.py @@ -2855,7 +2855,7 @@ class CmdSpawn(COMMAND_DEFAULT_CLASS): """ key = "@spawn" - aliases = ["@olc"] + aliases = ["olc"] switch_options = ("noloc", "search", "list", "show", "save", "delete", "menu", "olc", "update") locks = "cmd:perm(spawn) or perm(Builder)" help_category = "Building" diff --git a/evennia/prototypes/menus.py b/evennia/prototypes/menus.py index 182a8b63f4..0589a8e65c 100644 --- a/evennia/prototypes/menus.py +++ b/evennia/prototypes/menus.py @@ -281,7 +281,7 @@ def node_index(caller): {"desc": "|WPrototype-Key|n|n{}".format( _format_option_value("Key", "prototype_key" not in prototype, prototype, None)), "goto": "node_prototype_key"}) - for key in ('Typeclass', 'Prototype-parent', 'Key', 'Aliases', 'Attrs', 'Tags', 'Locks', + for key in ('Prototype-parent', 'Typeclass', 'Key', 'Aliases', 'Attrs', 'Tags', 'Locks', 'Permissions', 'Location', 'Home', 'Destination'): required = False cropper = None @@ -412,8 +412,9 @@ def _prototype_parent_examine(caller, prototype_name): def _prototype_parent_select(caller, prototype): ret = _set_property(caller, "", - prop="prototype_parent", processor=str, next_node="node_key") - caller.msg("Selected prototype |y{}|n. Removed any set typeclass parent.".format(prototype)) + prop="prototype_parent", processor=str, next_node="node_typeclass") + caller.msg("Selected prototype |y{}|n.".format(prototype)) + return ret @@ -442,15 +443,15 @@ def node_prototype_parent(caller): if prot_parent_key: prot_parent = protlib.search_prototype(prot_parent_key) if prot_parent: - text.format( + text = text.format( current="Current parent prototype is {}:\n{}".format( protlib.prototype_to_str(prot_parent))) else: - text.format( + text = text.format( current="Current parent prototype |r{prototype}|n " "does not appear to exist.".format(prot_parent_key)) else: - text.format(current="Parent prototype is not set") + text = text.format(current="Parent prototype is not set") text = (text, helptext) options = _wizard_options("prototype_parent", "prototype_key", "typeclass", color="|W") @@ -521,10 +522,10 @@ def node_typeclass(caller): """ if typeclass: - text.format( + text = text.format( current="Current typeclass is |y{typeclass}|n.".format(typeclass=typeclass)) else: - text.format( + text = text.format( current="Using default typeclass {typeclass}.".format( typeclass=settings.BASE_OBJECT_TYPECLASS)) @@ -558,9 +559,9 @@ def node_key(caller): """.format(pfuncs=_format_protfuncs()) if key: - text.format(current="Current key is '{key}'.".format(key=key)) + text = text.format(current="Current key is '{key}'.".format(key=key)) else: - text.format(current="The key is currently unset.") + text = text.format(current="The key is currently unset.") text = (text, helptext) @@ -597,9 +598,9 @@ def node_aliases(caller): """.format(pfuncs=_format_protfuncs()) if aliases: - text.format(current="Current aliases are '|c{aliases}|n'.".format(aliases=aliases)) + text = text.format(current="Current aliases are '|c{aliases}|n'.".format(aliases=aliases)) else: - text.format(current="No aliases are set.") + text = text.format(current="No aliases are set.") text = (text, helptext) @@ -729,10 +730,10 @@ def node_attrs(caller): """.format(pfuncs=_format_protfuncs()) if attrs: - text.format(current="Current attrs {attrs}.".format( + text = text.format(current="Current attrs {attrs}.".format( attrs=attrs)) else: - text.format(current="No attrs are set.") + text = text.format(current="No attrs are set.") text = (text, helptext) @@ -884,9 +885,9 @@ def node_locks(caller): """.format(lfuncs=_format_lockfuncs()) if locks: - text.format(current="Current locks are '|y{locks}|n'.".format(locks=locks)) + text = text.format(current="Current locks are '|y{locks}|n'.".format(locks=locks)) else: - text.format(current="No locks are set.") + text = text.format(current="No locks are set.") text = (text, helptext) @@ -925,10 +926,10 @@ def node_permissions(caller): """.format(settings.PERMISSION_HIERARCHY) if permissions: - text.format(current="Current permissions are {permissions}.".format( + text = text.format(current="Current permissions are {permissions}.".format( permissions=permissions)) else: - text.format(current="No permissions are set.") + text = text.format(current="No permissions are set.") text = (text, helptext) @@ -964,9 +965,9 @@ def node_location(caller): """.format(pfuncs=_format_protfuncs) if location: - text.format(current="Current location is {location}.".format(location=location)) + text = text.format(current="Current location is {location}.".format(location=location)) else: - text.format(current="Default location is {}'s inventory.".format(caller)) + text = text.format(current="Default location is {}'s inventory.".format(caller)) text = (text, helptext) @@ -1001,9 +1002,9 @@ def node_home(caller): """ if home: - text.format(current="Current home location is {home}.".format(home=home)) + text = text.format(current="Current home location is {home}.".format(home=home)) else: - text.format( + text = text.format( current="Default home location ({home}) used.".format(home=settings.DEFAULT_HOME)) text = (text, helptext) @@ -1036,9 +1037,9 @@ def node_destination(caller): """ if dest: - text.format(current="Current destination is {dest}.".format(dest=dest)) + text = text.format(current="Current destination is {dest}.".format(dest=dest)) else: - text.format("No destination is set (default).") + text = text.format("No destination is set (default).") text = (text, helptext) @@ -1070,9 +1071,9 @@ def node_prototype_desc(caller): """ if desc: - text.format(current="The current meta desc is:\n\"|w{desc}|n\"".format(desc=desc)) + text = text.format(current="The current meta desc is:\n\"|w{desc}|n\"".format(desc=desc)) else: - text.format(current="Prototype-Description is currently unset.") + text = text.format(current="Prototype-Description is currently unset.") text = (text, helptext) @@ -1094,6 +1095,8 @@ def node_prototype_tags(caller): text = """ |cPrototype-Tags|n can be used to classify and find prototypes in listings Tag names are not case-sensitive and can have not have a custom category. Separate multiple tags by commas. + + {current} """ helptext = """ Using prototype-tags is a good way to organize and group large numbers of prototypes by @@ -1104,9 +1107,9 @@ def node_prototype_tags(caller): tags = prototype.get('prototype_tags', []) if tags: - text.format(current="The current tags are:\n|w{tags}|n".format(tags=tags)) + text = text.format(current="The current tags are:\n|w{tags}|n".format(tags=tags)) else: - text.format(current="No tags are currently set.") + text = text.format(current="No tags are currently set.") text = (text, helptext) @@ -1147,9 +1150,9 @@ def node_prototype_locks(caller): """ if locks: - text.format(current="Current lock is |w'{lockstring}'|n".format(lockstring=locks)) + text = text.format(current="Current lock is |w'{lockstring}'|n".format(lockstring=locks)) else: - text.format( + text = text.format( current="Default lock set: |w'spawn:all(); edit:id({dbref}) or perm(Admin)'|n".format(dbref=caller.id)) text = (text, helptext) diff --git a/evennia/prototypes/spawner.py b/evennia/prototypes/spawner.py index c09a192819..494837b5fb 100644 --- a/evennia/prototypes/spawner.py +++ b/evennia/prototypes/spawner.py @@ -150,17 +150,34 @@ def _get_prototype(dic, prot, protparents): for infinite recursion here. """ - if "prototype" in dic: + if "prototype_parent" in dic: # move backwards through the inheritance - for prototype in make_iter(dic["prototype"]): + for prototype in make_iter(dic["prototype_parent"]): # Build the prot dictionary in reverse order, overloading new_prot = _get_prototype(protparents.get(prototype.lower(), {}), prot, protparents) prot.update(new_prot) prot.update(dic) - prot.pop("prototype", None) # we don't need this anymore + prot.pop("prototype_parent", None) # we don't need this anymore return prot +def flatten_prototype(prototype): + """ + Produce a 'flattened' prototype, where all prototype parents in the inheritance tree have been + merged into a final prototype. + + Args: + prototype (dict): Prototype to flatten. Its `prototype_parent` field will be parsed. + + Returns: + flattened (dict): The final, flattened prototype. + + """ + protparents = {prot['prototype_key'].lower(): prot for prot in protlib.search_prototype()} + protlib.validate_prototype(prototype, None, protparents, is_prototype_base=True) + return _get_prototype(prototype, {}, protparents) + + # obj-related prototype functions def prototype_from_object(obj): From abed588f5e405337ad1c2164e65c76a0e249faf6 Mon Sep 17 00:00:00 2001 From: Griatch Date: Wed, 25 Jul 2018 14:11:44 +0200 Subject: [PATCH 176/208] Show flattened current values in menu --- evennia/prototypes/menus.py | 170 +++++++++++----------------------- evennia/prototypes/spawner.py | 8 +- 2 files changed, 59 insertions(+), 119 deletions(-) diff --git a/evennia/prototypes/menus.py b/evennia/prototypes/menus.py index 0589a8e65c..e63acb98c2 100644 --- a/evennia/prototypes/menus.py +++ b/evennia/prototypes/menus.py @@ -42,6 +42,17 @@ def _get_menu_prototype(caller): return prototype +def _get_flat_menu_prototype(caller, refresh=False): + """Return prototype where parent values are included""" + flat_prototype = None + if not refresh and hasattr(caller.ndb._menutree, "olc_flat_prototype"): + flat_prototype = caller.ndb._menutree.olc_flat_prototype + if not flat_prototype: + prot = _get_menu_prototype(caller) + caller.ndb._menutree.olc_flat_prototype = flat_prototype = spawner.flatten_prototype(prot) + return flat_prototype + + def _set_menu_prototype(caller, prototype): """Set the prototype with existing one""" caller.ndb._menutree.olc_prototype = prototype @@ -230,6 +241,19 @@ def _format_lockfuncs(): docs=utils.justify(lockfunc.__doc__.strip(), align='l', indent=10).strip())) +def _get_current_value(caller, keyname, formatter=str): + "Return current value, marking if value comes from parent or set in this prototype" + prot = _get_menu_prototype(caller) + if keyname in prot: + # value in current prot + return "Current {}: {}".format(keyname, formatter(prot[keyname])) + flat_prot = _get_flat_menu_prototype(caller) + if keyname in flat_prot: + # value in flattened prot + return "Current {} (|binherited|n): {}".format(keyname, formatter(flat_prot[keyname])) + return "[No {} set]".format(keyname) + + # Menu nodes ------------------------------ @@ -363,12 +387,13 @@ def _check_prototype_key(caller, key): def node_prototype_key(caller): - prototype = _get_menu_prototype(caller) + text = """ The |cPrototype-Key|n uniquely identifies the prototype and is |wmandatory|n. It is used to find and use the prototype to spawn new entities. It is not case sensitive. - {current}""" + {current}""".format(current=_get_current_value(caller, "prototype_key")) + helptext = """ The prototype-key is not itself used when spawnng the new object, but is only used for managing, storing and loading the prototype. It must be globally unique, so existing keys @@ -376,12 +401,6 @@ def node_prototype_key(caller): prototype will be loaded. """ - old_key = prototype.get('prototype_key', None) - if old_key: - text = text.format(current="Currently set to '|w{key}|n'".format(key=old_key)) - else: - text = text.format(current="Currently |runset|n (required).") - options = _wizard_options("prototype_key", "index", "prototype_parent") options.append({"key": "_default", "goto": _check_prototype_key}) @@ -502,9 +521,6 @@ def _typeclass_select(caller, typeclass): @list_node(_all_typeclasses, _typeclass_select) def node_typeclass(caller): - prototype = _get_menu_prototype(caller) - typeclass = prototype.get("typeclass") - text = """ The |cTypeclass|n defines what 'type' of object this is - the actual working code to use. @@ -512,7 +528,8 @@ def node_typeclass(caller): one of the prototype's |cparents|n. {current} - """ + """.format(current=_get_current_value(caller, "typeclass")) + helptext = """ A |nTypeclass|n is specified by the actual python-path to the class definition in the Evennia code structure. @@ -521,14 +538,6 @@ def node_typeclass(caller): effects or expects certain values depend greatly on the code in play. """ - if typeclass: - text = text.format( - current="Current typeclass is |y{typeclass}|n.".format(typeclass=typeclass)) - else: - text = text.format( - current="Using default typeclass {typeclass}.".format( - typeclass=settings.BASE_OBJECT_TYPECLASS)) - text = (text, helptext) options = _wizard_options("typeclass", "prototype_parent", "key", color="|W") @@ -541,14 +550,12 @@ def node_typeclass(caller): def node_key(caller): - prototype = _get_menu_prototype(caller) - key = prototype.get("key") - text = """ The |cKey|n is the given name of the object to spawn. This will retain the given case. {current} - """ + """.format(current=_get_current_value(caller, "key")) + helptext = """ The key should often not be identical for every spawned object. Using a randomising $protfunc can be used, for example |c$choice(Alan, Tom, John)|n will give one of the three @@ -558,11 +565,6 @@ def node_key(caller): {pfuncs} """.format(pfuncs=_format_protfuncs()) - if key: - text = text.format(current="Current key is '{key}'.".format(key=key)) - else: - text = text.format(current="The key is currently unset.") - text = (text, helptext) options = _wizard_options("key", "typeclass", "aliases") @@ -578,8 +580,6 @@ def node_key(caller): def node_aliases(caller): - prototype = _get_menu_prototype(caller) - aliases = prototype.get("aliases") text = """ |cAliases|n are alternative ways to address an object, next to its |cKey|n. Aliases are not @@ -588,7 +588,8 @@ def node_aliases(caller): Add multiple aliases separating with commas. {current} - """ + """.format(current=_get_current_value(caller, "aliases")) + helptext = """ Aliases are fixed alternative identifiers and are stored with the new object. @@ -597,11 +598,6 @@ def node_aliases(caller): {pfuncs} """.format(pfuncs=_format_protfuncs()) - if aliases: - text = text.format(current="Current aliases are '|c{aliases}|n'.".format(aliases=aliases)) - else: - text = text.format(current="No aliases are set.") - text = (text, helptext) options = _wizard_options("aliases", "key", "attrs") @@ -703,8 +699,6 @@ def _examine_attr(caller, selection): @list_node(_caller_attrs) def node_attrs(caller): - prot = _get_menu_prototype(caller) - attrs = prot.get("attrs") text = """ |cAttributes|n are custom properties of the object. Enter attributes on one of these forms: @@ -717,7 +711,8 @@ def node_attrs(caller): (attrname;;lockstring=value). Attribute values can have embedded $protfuncs. {current} - """ + """.format(current=_get_current_value(caller, "attrs")) + helptext = """ Most commonly, Attributes don't need any categories or locks. If using locks, the lock-types 'attredit', 'attrread' are used to limiting editing and viewing of the Attribute. Putting @@ -729,12 +724,6 @@ def node_attrs(caller): {pfuncs} """.format(pfuncs=_format_protfuncs()) - if attrs: - text = text.format(current="Current attrs {attrs}.".format( - attrs=attrs)) - else: - text = text.format(current="No attrs are set.") - text = (text, helptext) options = _wizard_options("attrs", "aliases", "tags") @@ -835,7 +824,10 @@ def node_tags(caller): tagname tagname;category tagname;category;data - """ + + {current} + """.format(current=_get_current_value(caller, 'tags')) + helptext = """ Tags are shared between all objects with that tag. So the 'data' field (which is not commonly used) can only hold eventual info about the Tag itself, not about the individual @@ -855,8 +847,6 @@ def node_tags(caller): def node_locks(caller): - prototype = _get_menu_prototype(caller) - locks = prototype.get("locks") text = """ The |cLock string|n defines limitations for accessing various properties of the object once @@ -868,7 +858,8 @@ def node_locks(caller): Separate multiple lockstrings by semicolons (;). {current} - """ + """.format(current=_get_current_value(caller, 'locks')) + helptext = """ Here is an example of a lock string constisting of two locks: @@ -884,11 +875,6 @@ def node_locks(caller): {lfuncs} """.format(lfuncs=_format_lockfuncs()) - if locks: - text = text.format(current="Current locks are '|y{locks}|n'.".format(locks=locks)) - else: - text = text.format(current="No locks are set.") - text = (text, helptext) options = _wizard_options("locks", "tags", "permissions") @@ -904,15 +890,14 @@ def node_locks(caller): def node_permissions(caller): - prototype = _get_menu_prototype(caller) - permissions = prototype.get("permissions") text = """ |cPermissions|n are simple strings used to grant access to this object. A permission is used when a |clock|n is checked that contains the |wperm|n or |wpperm|n lock functions. {current} - """ + """.format(current=_get_current_value(caller, "permissions")) + helptext = """ Any string can act as a permission as long as a lock is set to look for it. Depending on the lock, having a permission could even be negative (i.e. the lock is only passed if you @@ -925,12 +910,6 @@ def node_permissions(caller): having the |cpermission|n "Builder" or higher. """.format(settings.PERMISSION_HIERARCHY) - if permissions: - text = text.format(current="Current permissions are {permissions}.".format( - permissions=permissions)) - else: - text = text.format(current="No permissions are set.") - text = (text, helptext) options = _wizard_options("permissions", "destination", "location") @@ -946,15 +925,14 @@ def node_permissions(caller): def node_location(caller): - prototype = _get_menu_prototype(caller) - location = prototype.get("location") text = """ The |cLocation|n of this object in the world. If not given, the object will spawn in the inventory of |c{caller}|n instead. {current} - """.format(caller=caller.key) + """.format(caller=caller.key, current=_get_current_value(caller, "location")) + helptext = """ You get the most control by not specifying the location - you can then teleport the spawned objects as needed later. Setting the location may be useful for quickly populating a given @@ -964,11 +942,6 @@ def node_location(caller): {pfuncs} """.format(pfuncs=_format_protfuncs) - if location: - text = text.format(current="Current location is {location}.".format(location=location)) - else: - text = text.format(current="Default location is {}'s inventory.".format(caller)) - text = (text, helptext) options = _wizard_options("location", "permissions", "home") @@ -984,8 +957,6 @@ def node_location(caller): def node_home(caller): - prototype = _get_menu_prototype(caller) - home = prototype.get("home") text = """ The |cHome|n location of an object is often only used as a backup - this is where the object @@ -993,7 +964,7 @@ def node_home(caller): home for characters to quickly move back to. If unset, the global home default will be used. {current} - """ + """.format(current=_get_current_value(caller, "home")) helptext = """ The location can be specified as as #dbref but can also be explicitly searched for using $obj(name). @@ -1001,12 +972,6 @@ def node_home(caller): The home location is often not used except as a backup. It should never be unset. """ - if home: - text = text.format(current="Current home location is {home}.".format(home=home)) - else: - text = text.format( - current="Default home location ({home}) used.".format(home=settings.DEFAULT_HOME)) - text = (text, helptext) options = _wizard_options("home", "aliases", "destination") @@ -1022,25 +987,19 @@ def node_home(caller): def node_destination(caller): - prototype = _get_menu_prototype(caller) - dest = prototype.get("dest") text = """ The object's |cDestination|n is usually only set for Exit-like objects and designates where the exit 'leads to'. It's usually unset for all other types of objects. {current} - """ + """.format(current=_get_current_node(caller, "destination")) + helptext = """ The destination can be given as a #dbref but can also be explicitly searched for using $obj(name). """ - if dest: - text = text.format(current="Current destination is {dest}.".format(dest=dest)) - else: - text = text.format("No destination is set (default).") - text = (text, helptext) options = _wizard_options("destination", "home", "prototype_desc") @@ -1057,24 +1016,17 @@ def node_destination(caller): def node_prototype_desc(caller): - prototype = _get_menu_prototype(caller) - desc = prototype.get("prototype_desc", None) - text = """ The |cPrototype-Description|n optionally briefly describes the prototype when it's viewed in listings. {current} - """ + """.format(current=_get_current_value(caller, "prototype_desc")) + helptext = """ Giving a brief description helps you and others to locate the prototype for use later. """ - if desc: - text = text.format(current="The current meta desc is:\n\"|w{desc}|n\"".format(desc=desc)) - else: - text = text.format(current="Prototype-Description is currently unset.") - text = (text, helptext) options = _wizard_options("prototype_desc", "prototype_key", "prototype_tags") @@ -1091,26 +1043,19 @@ def node_prototype_desc(caller): def node_prototype_tags(caller): - prototype = _get_menu_prototype(caller) + text = """ |cPrototype-Tags|n can be used to classify and find prototypes in listings Tag names are not case-sensitive and can have not have a custom category. Separate multiple tags by commas. {current} - """ + """.format(current=_get_current_value(caller, "prototype_tags")) helptext = """ Using prototype-tags is a good way to organize and group large numbers of prototypes by genre, type etc. Under the hood, prototypes' tags will all be stored with the category '{tagmetacategory}'. """.format(tagmetacategory=protlib._PROTOTYPE_TAG_META_CATEGORY) - tags = prototype.get('prototype_tags', []) - - if tags: - text = text.format(current="The current tags are:\n|w{tags}|n".format(tags=tags)) - else: - text = text.format(current="No tags are currently set.") - text = (text, helptext) options = _wizard_options("prototype_tags", "prototype_desc", "prototype_locks") @@ -1127,8 +1072,6 @@ def node_prototype_tags(caller): def node_prototype_locks(caller): - prototype = _get_menu_prototype(caller) - locks = prototype.get('prototype_locks', '') text = """ |cPrototype-Locks|n are used to limit access to this prototype when someone else is trying @@ -1142,19 +1085,14 @@ def node_prototype_locks(caller): If unsure, leave as default. {current} - """ + """.format(current=_get_current_value(caller, "prototype_locks")) + helptext = """ Prototype locks can be used when there are different tiers of builders or for developers to produce 'base prototypes' only meant for builders to inherit and expand on rather than change. """ - if locks: - text = text.format(current="Current lock is |w'{lockstring}'|n".format(lockstring=locks)) - else: - text = text.format( - current="Default lock set: |w'spawn:all(); edit:id({dbref}) or perm(Admin)'|n".format(dbref=caller.id)) - text = (text, helptext) options = _wizard_options("prototype_locks", "prototype_tags", "index") diff --git a/evennia/prototypes/spawner.py b/evennia/prototypes/spawner.py index 494837b5fb..31a77ce303 100644 --- a/evennia/prototypes/spawner.py +++ b/evennia/prototypes/spawner.py @@ -173,9 +173,11 @@ def flatten_prototype(prototype): flattened (dict): The final, flattened prototype. """ - protparents = {prot['prototype_key'].lower(): prot for prot in protlib.search_prototype()} - protlib.validate_prototype(prototype, None, protparents, is_prototype_base=True) - return _get_prototype(prototype, {}, protparents) + if prototype: + protparents = {prot['prototype_key'].lower(): prot for prot in protlib.search_prototype()} + protlib.validate_prototype(prototype, None, protparents, is_prototype_base=True) + return _get_prototype(prototype, {}, protparents) + return {} # obj-related prototype functions From cc5d9ffd4d939148ffc58402ecdea208c99f1dd4 Mon Sep 17 00:00:00 2001 From: Griatch Date: Wed, 25 Jul 2018 19:51:48 +0200 Subject: [PATCH 177/208] Validate prototype parent before chosing it --- evennia/prototypes/menus.py | 67 ++++++++++++++++++++------------ evennia/prototypes/prototypes.py | 12 +++--- evennia/prototypes/spawner.py | 6 ++- 3 files changed, 54 insertions(+), 31 deletions(-) diff --git a/evennia/prototypes/menus.py b/evennia/prototypes/menus.py index e63acb98c2..34f8eaf648 100644 --- a/evennia/prototypes/menus.py +++ b/evennia/prototypes/menus.py @@ -42,14 +42,15 @@ def _get_menu_prototype(caller): return prototype -def _get_flat_menu_prototype(caller, refresh=False): +def _get_flat_menu_prototype(caller, refresh=False, validate=False): """Return prototype where parent values are included""" flat_prototype = None if not refresh and hasattr(caller.ndb._menutree, "olc_flat_prototype"): flat_prototype = caller.ndb._menutree.olc_flat_prototype if not flat_prototype: prot = _get_menu_prototype(caller) - caller.ndb._menutree.olc_flat_prototype = flat_prototype = spawner.flatten_prototype(prot) + caller.ndb._menutree.olc_flat_prototype = \ + flat_prototype = spawner.flatten_prototype(prot, validate=validate) return flat_prototype @@ -305,11 +306,11 @@ def node_index(caller): {"desc": "|WPrototype-Key|n|n{}".format( _format_option_value("Key", "prototype_key" not in prototype, prototype, None)), "goto": "node_prototype_key"}) - for key in ('Prototype-parent', 'Typeclass', 'Key', 'Aliases', 'Attrs', 'Tags', 'Locks', + for key in ('Prototype_parent', 'Typeclass', 'Key', 'Aliases', 'Attrs', 'Tags', 'Locks', 'Permissions', 'Location', 'Home', 'Destination'): required = False cropper = None - if key in ("Prototype-parent", "Typeclass"): + if key in ("Prototype_parent", "Typeclass"): required = ("prototype_parent" not in prototype) and ("typeclass" not in prototype) if key == 'Typeclass': cropper = _path_cropper @@ -429,11 +430,24 @@ def _prototype_parent_examine(caller, prototype_name): caller.msg("Prototype not registered.") -def _prototype_parent_select(caller, prototype): - ret = _set_property(caller, "", - prop="prototype_parent", processor=str, next_node="node_typeclass") - caller.msg("Selected prototype |y{}|n.".format(prototype)) +def _prototype_parent_select(caller, new_parent): + ret = None + prototype_parent = protlib.search_prototype(new_parent) + try: + if prototype_parent: + spawner.flatten_prototype(prototype_parent[0], validate=True) + else: + raise RuntimeError("Not found.") + except RuntimeError as err: + caller.msg("Selected prototype parent {} " + "caused Error(s):\n|r{}|n".format(new_parent, err)) + else: + ret = _set_property(caller, new_parent, + prop="prototype_parent", + processor=str, next_node="node_prototype_parent") + _get_flat_menu_prototype(caller, refresh=True) + caller.msg("Selected prototype parent |c{}|n.".format(new_parent)) return ret @@ -441,12 +455,12 @@ def _prototype_parent_select(caller, prototype): def node_prototype_parent(caller): prototype = _get_menu_prototype(caller) - prot_parent_key = prototype.get('prototype') + prot_parent_keys = prototype.get('prototype_parent') text = """ The |cPrototype Parent|n allows you to |winherit|n prototype values from another named - prototype (given as that prototype's |wprototype_key|). If not changing these values in the - current prototype, the parent's value will be used. Pick the available prototypes below. + prototype (given as that prototype's |wprototype_key|n). If not changing these values in + the current prototype, the parent's value will be used. Pick the available prototypes below. Note that somewhere in the prototype's parentage, a |ctypeclass|n must be specified. If no parent is given, this prototype must define the typeclass (next menu node). @@ -459,18 +473,23 @@ def node_prototype_parent(caller): prototype to be valid. """ - if prot_parent_key: - prot_parent = protlib.search_prototype(prot_parent_key) - if prot_parent: - text = text.format( - current="Current parent prototype is {}:\n{}".format( - protlib.prototype_to_str(prot_parent))) - else: - text = text.format( - current="Current parent prototype |r{prototype}|n " - "does not appear to exist.".format(prot_parent_key)) - else: - text = text.format(current="Parent prototype is not set") + ptexts = [] + if prot_parent_keys: + for pkey in utils.make_iter(prot_parent_keys): + prot_parent = protlib.search_prototype(pkey) + if prot_parent: + prot_parent = prot_parent[0] + ptexts.append("|c -- {pkey} -- |n\n{prot}".format( + pkey=pkey, + prot=protlib.prototype_to_str(prot_parent))) + else: + ptexts.append("Prototype parent |r{pkey} was not found.".format(pkey=pkey)) + + if not ptexts: + ptexts.append("[No prototype_parent set]") + + text = text.format(current="\n\n".join(ptexts)) + text = (text, helptext) options = _wizard_options("prototype_parent", "prototype_key", "typeclass", color="|W") @@ -993,7 +1012,7 @@ def node_destination(caller): the exit 'leads to'. It's usually unset for all other types of objects. {current} - """.format(current=_get_current_node(caller, "destination")) + """.format(current=_get_current_value(caller, "destination")) helptext = """ The destination can be given as a #dbref but can also be explicitly searched for using diff --git a/evennia/prototypes/prototypes.py b/evennia/prototypes/prototypes.py index 011445b039..767919a7a9 100644 --- a/evennia/prototypes/prototypes.py +++ b/evennia/prototypes/prototypes.py @@ -539,7 +539,7 @@ def list_prototypes(caller, key=None, tags=None, show_non_use=False, show_non_ed def validate_prototype(prototype, protkey=None, protparents=None, - is_prototype_base=True, _flags=None): + is_prototype_base=True, strict=True, _flags=None): """ Run validation on a prototype, checking for inifinite regress. @@ -552,6 +552,8 @@ def validate_prototype(prototype, protkey=None, protparents=None, is_prototype_base (bool, optional): We are trying to create a new object *based on this object*. This means we can't allow 'mixin'-style prototypes without typeclass/parent etc. + strict (bool, optional): If unset, don't require needed keys, only check against infinite + recursion etc. _flags (dict, optional): Internal work dict that should not be set externally. Raises: RuntimeError: If prototype has invalid structure. @@ -570,14 +572,14 @@ def validate_prototype(prototype, protkey=None, protparents=None, protkey = protkey and protkey.lower() or prototype.get('prototype_key', None) - if not bool(protkey): + if strict and not bool(protkey): _flags['errors'].append("Prototype lacks a `prototype_key`.") protkey = "[UNSET]" typeclass = prototype.get('typeclass') prototype_parent = prototype.get('prototype_parent', []) - if not (typeclass or prototype_parent): + if strict and not (typeclass or prototype_parent): if is_prototype_base: _flags['errors'].append("Prototype {} requires `typeclass` " "or 'prototype_parent'.".format(protkey)) @@ -585,7 +587,7 @@ def validate_prototype(prototype, protkey=None, protparents=None, _flags['warnings'].append("Prototype {} can only be used as a mixin since it lacks " "a typeclass or a prototype_parent.".format(protkey)) - if typeclass and typeclass not in get_all_typeclasses("evennia.objects.models.ObjectDB"): + if strict and typeclass and typeclass not in get_all_typeclasses("evennia.objects.models.ObjectDB"): _flags['errors'].append( "Prototype {} is based on typeclass {}, which could not be imported!".format( protkey, typeclass)) @@ -615,7 +617,7 @@ def validate_prototype(prototype, protkey=None, protparents=None, _flags['typeclass'] = typeclass # if we get back to the current level without a typeclass it's an error. - if is_prototype_base and _flags['depth'] <= 0 and not _flags['typeclass']: + if strict and is_prototype_base and _flags['depth'] <= 0 and not _flags['typeclass']: _flags['errors'].append("Prototype {} has no `typeclass` defined anywhere in its parent " "chain. Add `typeclass`, or a `prototype_parent` pointing to a " "prototype with a typeclass.".format(protkey)) diff --git a/evennia/prototypes/spawner.py b/evennia/prototypes/spawner.py index 31a77ce303..3dd8e11d67 100644 --- a/evennia/prototypes/spawner.py +++ b/evennia/prototypes/spawner.py @@ -161,13 +161,14 @@ def _get_prototype(dic, prot, protparents): return prot -def flatten_prototype(prototype): +def flatten_prototype(prototype, validate=False): """ Produce a 'flattened' prototype, where all prototype parents in the inheritance tree have been merged into a final prototype. Args: prototype (dict): Prototype to flatten. Its `prototype_parent` field will be parsed. + validate (bool, optional): Validate for valid keys etc. Returns: flattened (dict): The final, flattened prototype. @@ -175,7 +176,8 @@ def flatten_prototype(prototype): """ if prototype: protparents = {prot['prototype_key'].lower(): prot for prot in protlib.search_prototype()} - protlib.validate_prototype(prototype, None, protparents, is_prototype_base=True) + protlib.validate_prototype(prototype, None, protparents, + is_prototype_base=validate, strict=validate) return _get_prototype(prototype, {}, protparents) return {} From ef131f6f5bc12b366fd2fe56f324e550daf95e44 Mon Sep 17 00:00:00 2001 From: Griatch Date: Thu, 26 Jul 2018 23:41:00 +0200 Subject: [PATCH 178/208] Refactor menu up until attrs --- evennia/prototypes/menus.py | 376 +++++++++++++++++++++++++------ evennia/prototypes/prototypes.py | 4 +- evennia/prototypes/tests.py | 31 ++- evennia/utils/evmenu.py | 2 +- 4 files changed, 329 insertions(+), 84 deletions(-) diff --git a/evennia/prototypes/menus.py b/evennia/prototypes/menus.py index 34f8eaf648..54ec054340 100644 --- a/evennia/prototypes/menus.py +++ b/evennia/prototypes/menus.py @@ -5,6 +5,7 @@ OLC Prototype menu nodes """ import json +import re from random import choice from django.conf import settings from evennia.utils.evmenu import EvMenu, list_node @@ -242,6 +243,25 @@ def _format_lockfuncs(): docs=utils.justify(lockfunc.__doc__.strip(), align='l', indent=10).strip())) +def _format_list_actions(*args, **kwargs): + """Create footer text for nodes with extra list actions + + Args: + actions (str): Available actions. The first letter of the action name will be assumed + to be a shortcut. + Kwargs: + prefix (str): Default prefix to use. + Returns: + string (str): Formatted footer for adding to the node text. + + """ + actions = [] + prefix = kwargs.get('prefix', "|WSelect with |w|W. Other actions:|n ") + for action in args: + actions.append("|w{}|n|W{} |w|n".format(action[0], action[1:])) + return prefix + "|W,|n ".join(actions) + + def _get_current_value(caller, keyname, formatter=str): "Return current value, marking if value comes from parent or set in this prototype" prot = _get_menu_prototype(caller) @@ -255,6 +275,32 @@ def _get_current_value(caller, keyname, formatter=str): return "[No {} set]".format(keyname) +def _default_parse(raw_inp, choices, *args): + """ + Helper to parse default input to a node decorated with the node_list decorator on + the form l1, l 2, look 1, etc. Spaces are ignored, as is case. + + Args: + raw_inp (str): Input from the user. + choices (list): List of available options on the node listing (list of strings). + args (tuples): The available actions, each specifed as a tuple (name, alias, ...) + Returns: + choice (str): A choice among the choices, or None if no match was found. + action (str): The action operating on the choice, or None. + + """ + raw_inp = raw_inp.lower().strip() + mapping = {t.lower(): tup[0] for tup in args for t in tup} + match = re.match(r"(%s)\s*?(\d+)$" % "|".join(mapping.keys()), raw_inp) + if match: + action = mapping.get(match.group(1), None) + num = int(match.group(2)) - 1 + num = num if 0 <= num < len(choices) else None + if action is not None and num is not None: + return choices[num], action + return None, None + + # Menu nodes ------------------------------ @@ -357,6 +403,26 @@ def node_validate_prototype(caller, raw_string, **kwargs): text = (text, helptext) options = _wizard_options(None, prev_node, None) + options.append({"key": "_default", + "goto": "node_" + prev_node}) + + return text, options + + +def node_examine_entity(caller, raw_string, **kwargs): + """ + General node to view a text and then return to previous node. Kwargs should contain "text" for + the text to show and 'back" pointing to the node to return to. + """ + text = kwargs.get("text", "Nothing was found here.") + helptext = "Use |wback|n to return to the previous node." + prev_node = kwargs.get('back', 'index') + + text = (text, helptext) + + options = _wizard_options(None, prev_node, None) + options.append({"key": "_default", + "goto": "node_" + prev_node}) return text, options @@ -419,15 +485,64 @@ def _all_prototype_parents(caller): for prototype in protlib.search_prototype() if "prototype_key" in prototype] -def _prototype_parent_examine(caller, prototype_name): - """Convert prototype to a string representation for closer inspection""" - prototypes = protlib.search_prototype(key=prototype_name) - if prototypes: - ret = protlib.prototype_to_str(prototypes[0]) - caller.msg(ret) - return ret - else: - caller.msg("Prototype not registered.") +def _prototype_parent_actions(caller, raw_inp, **kwargs): + """Parse the default Convert prototype to a string representation for closer inspection""" + choices = kwargs.get("available_choices", []) + prototype_parent, action = _default_parse( + raw_inp, choices, ("examine", "e", "l"), ("add", "a"), ("remove", "r", 'delete', 'd')) + + if prototype_parent: + # a selection of parent was made + prototype_parent = protlib.search_prototype(key=prototype_parent)[0] + prototype_parent_key = prototype_parent['prototype_key'] + + # which action to apply on the selection + if action == 'examine': + # examine the prototype + txt = protlib.prototype_to_str(prototype_parent) + kwargs['text'] = txt + kwargs['back'] = 'prototype_parent' + return "node_examine_entity", kwargs + elif action == 'add': + # add/append parent + prot = _get_menu_prototype(caller) + current_prot_parent = prot.get('prototype_parent', None) + if current_prot_parent: + current_prot_parent = utils.make_iter(current_prot_parent) + if prototype_parent_key in current_prot_parent: + caller.msg("Prototype_parent {} is already used.".format(prototype_parent_key)) + return "node_prototype_parent" + else: + current_prot_parent.append(prototype_parent_key) + caller.msg("Add prototype parent for multi-inheritance.") + else: + current_prot_parent = prototype_parent_key + try: + if prototype_parent: + spawner.flatten_prototype(prototype_parent, validate=True) + else: + raise RuntimeError("Not found.") + except RuntimeError as err: + caller.msg("Selected prototype-parent {} " + "caused Error(s):\n|r{}|n".format(prototype_parent, err)) + return "node_prototype_parent" + _set_prototype_value(caller, "prototype_parent", current_prot_parent) + _get_flat_menu_prototype(caller, refresh=True) + elif action == "remove": + # remove prototype parent + prot = _get_menu_prototype(caller) + current_prot_parent = prot.get('prototype_parent', None) + if current_prot_parent: + current_prot_parent = utils.make_iter(current_prot_parent) + try: + current_prot_parent.remove(prototype_parent_key) + _set_prototype_value(caller, 'prototype_parent', current_prot_parent) + _get_flat_menu_prototype(caller, refresh=True) + caller.msg("Removed prototype parent {}.".format(prototype_parent_key)) + except ValueError: + caller.msg("|rPrototype-parent {} could not be removed.".format( + prototype_parent_key)) + return 'node_prototype_parent' def _prototype_parent_select(caller, new_parent): @@ -440,7 +555,7 @@ def _prototype_parent_select(caller, new_parent): else: raise RuntimeError("Not found.") except RuntimeError as err: - caller.msg("Selected prototype parent {} " + caller.msg("Selected prototype-parent {} " "caused Error(s):\n|r{}|n".format(new_parent, err)) else: ret = _set_property(caller, new_parent, @@ -466,6 +581,8 @@ def node_prototype_parent(caller): parent is given, this prototype must define the typeclass (next menu node). {current} + + {actions} """ helptext = """ Prototypes can inherit from one another. Changes in the child replace any values set in a @@ -488,13 +605,14 @@ def node_prototype_parent(caller): if not ptexts: ptexts.append("[No prototype_parent set]") - text = text.format(current="\n\n".join(ptexts)) + text = text.format(current="\n\n".join(ptexts), + actions=_format_list_actions("examine", "add", "remove")) text = (text, helptext) options = _wizard_options("prototype_parent", "prototype_key", "typeclass", color="|W") options.append({"key": "_default", - "goto": _prototype_parent_examine}) + "goto": _prototype_parent_actions}) return text, options @@ -508,33 +626,45 @@ def _all_typeclasses(caller): if name != "evennia.objects.models.ObjectDB") -def _typeclass_examine(caller, typeclass_path): - """Show info (docstring) about given typeclass.""" - if typeclass_path is None: - # this means we are exiting the listing - return "node_key" +def _typeclass_actions(caller, raw_inp, **kwargs): + """Parse actions for typeclass listing""" - typeclass = utils.get_all_typeclasses().get(typeclass_path) - if typeclass: - docstr = [] - for line in typeclass.__doc__.split("\n"): - if line.strip(): - docstr.append(line) - elif docstr: - break - docstr = '\n'.join(docstr) if docstr else "" - txt = "Typeclass |y{typeclass_path}|n; First paragraph of docstring:\n\n{docstring}".format( - typeclass_path=typeclass_path, docstring=docstr) - else: - txt = "This is typeclass |y{}|n.".format(typeclass) - caller.msg(txt) - return txt + choices = kwargs.get("available_choices", []) + typeclass_path, action = _default_parse( + raw_inp, choices, ("examine", "e", "l"), ("remove", "r", "delete", "d")) + + if typeclass_path: + if action == 'examine': + typeclass = utils.get_all_typeclasses().get(typeclass_path) + if typeclass: + docstr = [] + for line in typeclass.__doc__.split("\n"): + if line.strip(): + docstr.append(line) + elif docstr: + break + docstr = '\n'.join(docstr) if docstr else "" + txt = "Typeclass |c{typeclass_path}|n; " \ + "First paragraph of docstring:\n\n{docstring}".format( + typeclass_path=typeclass_path, docstring=docstr) + else: + txt = "This is typeclass |y{}|n.".format(typeclass) + return "node_examine_entity", {"text": txt, "back": "typeclass"} + elif action == 'remove': + prototype = _get_menu_prototype(caller) + old_typeclass = prototype.pop('typeclass', None) + if old_typeclass: + _set_menu_prototype(caller, prototype) + caller.msg("Cleared typeclass {}.".format(old_typeclass)) + else: + caller.msg("No typeclass to remove.") + return "node_typeclass" def _typeclass_select(caller, typeclass): """Select typeclass from list and add it to prototype. Return next node to go to.""" ret = _set_property(caller, typeclass, prop='typeclass', processor=str, next_node="node_key") - caller.msg("Selected typeclass |y{}|n.".format(typeclass)) + caller.msg("Selected typeclass |c{}|n.".format(typeclass)) return ret @@ -547,7 +677,10 @@ def node_typeclass(caller): one of the prototype's |cparents|n. {current} - """.format(current=_get_current_value(caller, "typeclass")) + + {actions} + """.format(current=_get_current_value(caller, "typeclass"), + actions=_format_list_actions("examine", "remove")) helptext = """ A |nTypeclass|n is specified by the actual python-path to the class definition in the @@ -561,7 +694,7 @@ def node_typeclass(caller): options = _wizard_options("typeclass", "prototype_parent", "key", color="|W") options.append({"key": "_default", - "goto": _typeclass_examine}) + "goto": _typeclass_actions}) return text, options @@ -598,16 +731,62 @@ def node_key(caller): # aliases node +def _all_aliases(caller): + "Get aliases in prototype" + prototype = _get_menu_prototype(caller) + return prototype.get("aliases", []) + + +def _aliases_select(caller, alias): + "Add numbers as aliases" + aliases = _all_aliases(caller) + try: + ind = str(aliases.index(alias) + 1) + if ind not in aliases: + aliases.append(ind) + _set_prototype_value(caller, "aliases", aliases) + caller.msg("Added alias '{}'.".format(ind)) + except (IndexError, ValueError) as err: + caller.msg("Error: {}".format(err)) + + return "node_aliases" + + +def _aliases_actions(caller, raw_inp, **kwargs): + """Parse actions for aliases listing""" + choices = kwargs.get("available_choices", []) + alias, action = _default_parse( + raw_inp, choices, ("remove", "r", "delete", "d")) + + aliases = _all_aliases(caller) + if alias and action == 'remove': + try: + aliases.remove(alias) + _set_prototype_value(caller, "aliases", aliases) + caller.msg("Removed alias '{}'.".format(alias)) + except ValueError: + caller.msg("No matching alias found to remove.") + else: + # if not a valid remove, add as a new alias + alias = raw_inp.lower().strip() + if alias not in aliases: + aliases.append(alias) + _set_prototype_value(caller, "aliases", aliases) + caller.msg("Added alias '{}'.".format(alias)) + else: + caller.msg("Alias '{}' was already set.".format(alias)) + return "node_aliases" + + +@list_node(_all_aliases, _aliases_select) def node_aliases(caller): text = """ |cAliases|n are alternative ways to address an object, next to its |cKey|n. Aliases are not case sensitive. - Add multiple aliases separating with commas. - - {current} - """.format(current=_get_current_value(caller, "aliases")) + {actions} + """.format(_format_list_actions("remove", prefix="|w|W to add new alias. Other action: ")) helptext = """ Aliases are fixed alternative identifiers and are stored with the new object. @@ -621,10 +800,7 @@ def node_aliases(caller): options = _wizard_options("aliases", "key", "attrs") options.append({"key": "_default", - "goto": (_set_property, - dict(prop="aliases", - processor=lambda s: [part.strip() for part in s.split(",")], - next_node="node_attrs"))}) + "goto": _aliases_actions}) return text, options @@ -633,38 +809,62 @@ def node_aliases(caller): def _caller_attrs(caller): prototype = _get_menu_prototype(caller) - attrs = prototype.get("attrs", []) + attrs = ["{}={}".format(tup[0], utils.crop(utils.to_str(tup[1]), width=10)) + for tup in prototype.get("attrs", [])] return attrs +def _get_tup_by_attrname(caller, attrname): + prototype = _get_menu_prototype(caller) + attrs = prototype.get("attrs", []) + try: + inp = [tup[0] for tup in attrs].index(attrname) + return attrs[inp] + except ValueError: + return None + + def _display_attribute(attr_tuple): """Pretty-print attribute tuple""" attrkey, value, category, locks = attr_tuple value = protlib.protfunc_parser(value) typ = type(value) - out = ("Attribute key: '{attrkey}' (category: {category}, " - "locks: {locks})\n" - "Value (parsed to {typ}): {value}").format( + out = ("|cAttribute key:|n '{attrkey}' " + "(|ccategory:|n {category}, " + "|clocks:|n {locks})\n" + "|cValue|n |W(parsed to {typ})|n:\n{value}").format( attrkey=attrkey, - category=category, locks=locks, + category=category if category else "|wNone|n", + locks=locks if locks else "|wNone|n", typ=typ, value=value) return out def _add_attr(caller, attr_string, **kwargs): """ - Add new attrubute, parsing input. - attr is entered on these forms - attr = value - attr;category = value - attr;category;lockstring = value + Add new attribute, parsing input. + Args: + caller (Object): Caller of menu. + attr_string (str): Input from user + attr is entered on these forms + attr = value + attr;category = value + attr;category;lockstring = value + Kwargs: + delete (str): If this is set, attr_string is + considered the name of the attribute to delete and + no further parsing happens. + Returns: + result (str): Result string of action. """ attrname = '' category = None locks = '' - if '=' in attr_string: + if 'delete' in kwargs: + attrname = attr_string + elif '=' in attr_string: attrname, value = (part.strip() for part in attr_string.split('=', 1)) attrname = attrname.lower() nameparts = attrname.split(";", 2) @@ -679,6 +879,15 @@ def _add_attr(caller, attr_string, **kwargs): prot = _get_menu_prototype(caller) attrs = prot.get('attrs', []) + if 'delete' in kwargs: + try: + ind = [tup[0] for tup in attrs].index(attrname) + del attrs[ind] + _set_prototype_value(caller, "attrs", attrs) + return "Removed Attribute '{}'".format(attrname) + except IndexError: + return "Attribute to delete not found." + try: # replace existing attribute with the same name in the prototype ind = [tup[0] for tup in attrs].index(attrname) @@ -697,26 +906,47 @@ def _add_attr(caller, attr_string, **kwargs): else: text = "Attribute must be given as 'attrname[;category;locks] = '." - options = {"key": "_default", - "goto": lambda caller: None} - return text, options + return text -def _edit_attr(caller, attrname, new_value, **kwargs): +def _attr_select(caller, attrstr): + attrname, _ = attrstr.split("=", 1) + attrname = attrname.strip() - attr_string = "{}={}".format(attrname, new_value) - - return _add_attr(caller, attr_string, edit=True) + attr_tup = _get_tup_by_attrname(caller, attrname) + if attr_tup: + return "node_examine_entity", \ + {"text": _display_attribute(attr_tup), "back": "attrs"} + else: + caller.msg("Attribute not found.") + return "node_attrs" -def _examine_attr(caller, selection): - prot = _get_menu_prototype(caller) - ind = [part[0] for part in prot['attrs']].index(selection) - attr_tuple = prot['attrs'][ind] - return _display_attribute(attr_tuple) +def _attrs_actions(caller, raw_inp, **kwargs): + """Parse actions for attribute listing""" + choices = kwargs.get("available_choices", []) + attrstr, action = _default_parse( + raw_inp, choices, ('examine', 'e'), ('remove', 'r', 'delete', 'd')) + if attrstr is None: + attrstr = raw_inp + attrname, _ = attrstr.split("=", 1) + attrname = attrname.strip() + attr_tup = _get_tup_by_attrname(caller, attrname) + + if attr_tup: + if action == 'examine': + return "node_examine_entity", \ + {"text": _display_attribute(attr_tup), "back": "attrs"} + elif action == 'remove': + res = _add_attr(caller, attr_tup, delete=True) + caller.msg(res) + else: + res = _add_attr(caller, raw_inp) + caller.msg(res) + return "node_attrs" -@list_node(_caller_attrs) +@list_node(_caller_attrs, _attr_select) def node_attrs(caller): text = """ @@ -729,8 +959,8 @@ def node_attrs(caller): To give an attribute without a category but with a lockstring, leave that spot empty (attrname;;lockstring=value). Attribute values can have embedded $protfuncs. - {current} - """.format(current=_get_current_value(caller, "attrs")) + {actions} + """.format(actions=_format_list_actions("examine", "remove", prefix="Actions: ")) helptext = """ Most commonly, Attributes don't need any categories or locks. If using locks, the lock-types @@ -747,10 +977,7 @@ def node_attrs(caller): options = _wizard_options("attrs", "aliases", "tags") options.append({"key": "_default", - "goto": (_set_property, - dict(prop="attrs", - processor=lambda s: [part.strip() for part in s.split(",")], - next_node="node_tags"))}) + "goto": _attrs_actions}) return text, options @@ -1410,7 +1637,7 @@ def node_prototype_load(caller, **kwargs): options = _wizard_options("prototype_load", "prototype_save", "index") options.append({"key": "_default", - "goto": _prototype_parent_examine}) + "goto": _prototype_parent_actions}) return text, options @@ -1468,6 +1695,7 @@ def start_olc(caller, session=None, prototype=None): """ menudata = {"node_index": node_index, "node_validate_prototype": node_validate_prototype, + "node_examine_entity": node_examine_entity, "node_prototype_key": node_prototype_key, "node_prototype_parent": node_prototype_parent, "node_typeclass": node_typeclass, diff --git a/evennia/prototypes/prototypes.py b/evennia/prototypes/prototypes.py index 767919a7a9..4c0a2d3186 100644 --- a/evennia/prototypes/prototypes.py +++ b/evennia/prototypes/prototypes.py @@ -606,6 +606,8 @@ def validate_prototype(prototype, protkey=None, protparents=None, _flags['errors'].append( "{} has infinite nesting of prototypes.".format(protkey or prototype)) + if _flags['errors']: + raise RuntimeError("Error: " + "\nError: ".join(_flags['errors'])) _flags['visited'].append(id(prototype)) _flags['depth'] += 1 validate_prototype(protparent, protstring, protparents, @@ -618,7 +620,7 @@ def validate_prototype(prototype, protkey=None, protparents=None, # if we get back to the current level without a typeclass it's an error. if strict and is_prototype_base and _flags['depth'] <= 0 and not _flags['typeclass']: - _flags['errors'].append("Prototype {} has no `typeclass` defined anywhere in its parent " + _flags['errors'].append("Prototype {} has no `typeclass` defined anywhere in its parent\n " "chain. Add `typeclass`, or a `prototype_parent` pointing to a " "prototype with a typeclass.".format(protkey)) diff --git a/evennia/prototypes/tests.py b/evennia/prototypes/tests.py index 221200672d..4b16ad9ab2 100644 --- a/evennia/prototypes/tests.py +++ b/evennia/prototypes/tests.py @@ -384,6 +384,14 @@ class TestMenuModule(EvenniaTest): {"prototype_key": "testthing", "key": "mytest"}), (True, Something)) + choices = ["test1", "test2", "test3", "test4"] + actions = (("examine", "e", "l"), ("add", "a"), ("foo", "f")) + self.assertEqual(olc_menus._default_parse("l4", choices, *actions), ('test4', 'examine')) + self.assertEqual(olc_menus._default_parse("add 2", choices, *actions), ('test2', 'add')) + self.assertEqual(olc_menus._default_parse("foo3", choices, *actions), ('test3', 'foo')) + self.assertEqual(olc_menus._default_parse("f3", choices, *actions), ('test3', 'foo')) + self.assertEqual(olc_menus._default_parse("f5", choices, *actions), (None, None)) + def test_node_helpers(self): caller = self.caller @@ -399,15 +407,20 @@ class TestMenuModule(EvenniaTest): # prototype_parent helpers self.assertEqual(olc_menus._all_prototype_parents(caller), ['test_prot']) - self.assertEqual(olc_menus._prototype_parent_examine( - caller, 'test_prot'), - "|cprototype key:|n test_prot, |ctags:|n None, |clocks:|n edit:all();spawn:all() " - "\n|cdesc:|n None \n|cprototype:|n " - "{\n 'typeclass': 'evennia.objects.objects.DefaultObject', \n}") - self.assertEqual(olc_menus._prototype_parent_select(caller, self.test_prot), "node_key") + # self.assertEqual(olc_menus._prototype_parent_parse( + # caller, 'test_prot'), + # "|cprototype key:|n test_prot, |ctags:|n None, |clocks:|n edit:all();spawn:all() " + # "\n|cdesc:|n None \n|cprototype:|n " + # "{\n 'typeclass': 'evennia.objects.objects.DefaultObject', \n}") + + with mock.patch("evennia.prototypes.menus.protlib.search_prototype", + new=mock.MagicMock(return_value=[_PROTPARENTS['GOBLIN']])): + self.assertEqual(olc_menus._prototype_parent_select(caller, "goblin"), "node_prototype_parent") + self.assertEqual(olc_menus._get_menu_prototype(caller), {'prototype_key': 'test_prot', 'prototype_locks': 'edit:all();spawn:all()', + 'prototype_parent': 'goblin', 'typeclass': 'evennia.objects.objects.DefaultObject'}) # typeclass helpers @@ -423,6 +436,7 @@ class TestMenuModule(EvenniaTest): self.assertEqual(olc_menus._get_menu_prototype(caller), {'prototype_key': 'test_prot', 'prototype_locks': 'edit:all();spawn:all()', + 'prototype_parent': 'goblin', 'typeclass': 'evennia.objects.objects.DefaultObject'}) # attr helpers @@ -459,7 +473,9 @@ class TestMenuModule(EvenniaTest): protlib.save_prototype(**self.test_prot) # spawn helpers - obj = olc_menus._spawn(caller, prototype=self.test_prot) + with mock.patch("evennia.prototypes.menus.protlib.search_prototype", + new=mock.MagicMock(return_value=[_PROTPARENTS['GOBLIN']])): + obj = olc_menus._spawn(caller, prototype=self.test_prot) self.assertEqual(obj.typeclass_path, "evennia.objects.objects.DefaultObject") self.assertEqual(obj.tags.get(category=spawner._PROTOTYPE_TAG_CATEGORY), self.test_prot['prototype_key']) @@ -475,7 +491,6 @@ class TestMenuModule(EvenniaTest): self.assertEqual(olc_menus._prototype_load_select(caller, self.test_prot['prototype_key']), "node_index") - @mock.patch("evennia.prototypes.menus.protlib.search_prototype", new=mock.MagicMock( return_value=[{"prototype_key": "TestPrototype", "typeclass": "TypeClassTest", "key": "TestObj"}])) diff --git a/evennia/utils/evmenu.py b/evennia/utils/evmenu.py index 2f1b7d64fa..d21aec2c56 100644 --- a/evennia/utils/evmenu.py +++ b/evennia/utils/evmenu.py @@ -938,7 +938,7 @@ class EvMenu(object): for key, desc in optionlist: if not (key or desc): continue - desc_string = ": %s" % desc if desc else "" + desc_string = ": %s" % (desc if desc else "") table_width_max = max(table_width_max, max(m_len(p) for p in key.split("\n")) + max(m_len(p) for p in desc_string.split("\n")) + colsep) From 0c53088e515f450c7a8881b7311ca3f42c57fecf Mon Sep 17 00:00:00 2001 From: Griatch Date: Fri, 27 Jul 2018 13:34:20 +0200 Subject: [PATCH 179/208] Add tag handling in old menu --- evennia/prototypes/menus.py | 163 +++++++++++++++++++++++------------- evennia/prototypes/tests.py | 37 ++++---- 2 files changed, 123 insertions(+), 77 deletions(-) diff --git a/evennia/prototypes/menus.py b/evennia/prototypes/menus.py index 54ec054340..e5ff1179a2 100644 --- a/evennia/prototypes/menus.py +++ b/evennia/prototypes/menus.py @@ -863,7 +863,7 @@ def _add_attr(caller, attr_string, **kwargs): locks = '' if 'delete' in kwargs: - attrname = attr_string + attrname = attr_string.lower().strip() elif '=' in attr_string: attrname, value = (part.strip() for part in attr_string.split('=', 1)) attrname = attrname.lower() @@ -892,17 +892,12 @@ def _add_attr(caller, attr_string, **kwargs): # replace existing attribute with the same name in the prototype ind = [tup[0] for tup in attrs].index(attrname) attrs[ind] = attr_tuple + text = "Edited Attribute '{}'.".format(attrname) except ValueError: attrs.append(attr_tuple) + text = "Added Attribute " + _display_attribute(attr_tuple) _set_prototype_value(caller, "attrs", attrs) - - text = kwargs.get('text') - if not text: - if 'edit' in kwargs: - text = "Edited " + _display_attribute(attr_tuple) - else: - text = "Added " + _display_attribute(attr_tuple) else: text = "Attribute must be given as 'attrname[;category;locks] = '." @@ -929,7 +924,12 @@ def _attrs_actions(caller, raw_inp, **kwargs): raw_inp, choices, ('examine', 'e'), ('remove', 'r', 'delete', 'd')) if attrstr is None: attrstr = raw_inp - attrname, _ = attrstr.split("=", 1) + try: + attrname, _ = attrstr.split("=", 1) + except ValueError: + caller.msg("|rNeed to enter the attribute on the form attrname=value.|n") + return "node_attrs" + attrname = attrname.strip() attr_tup = _get_tup_by_attrname(caller, attrname) @@ -938,7 +938,7 @@ def _attrs_actions(caller, raw_inp, **kwargs): return "node_examine_entity", \ {"text": _display_attribute(attr_tup), "back": "attrs"} elif action == 'remove': - res = _add_attr(caller, attr_tup, delete=True) + res = _add_attr(caller, attrname, delete=True) caller.msg(res) else: res = _add_attr(caller, raw_inp) @@ -964,9 +964,9 @@ def node_attrs(caller): helptext = """ Most commonly, Attributes don't need any categories or locks. If using locks, the lock-types - 'attredit', 'attrread' are used to limiting editing and viewing of the Attribute. Putting + 'attredit' and 'attrread' are used to limit editing and viewing of the Attribute. Putting the lock-type `attrcreate` in the |clocks|n prototype key can be used to restrict builders - to add new Attributes. + from adding new Attributes. |c$protfuncs @@ -986,83 +986,128 @@ def node_attrs(caller): def _caller_tags(caller): prototype = _get_menu_prototype(caller) - tags = prototype.get("tags", []) + tags = [tup[0] for tup in prototype.get("tags", [])] return tags +def _get_tup_by_tagname(caller, tagname): + prototype = _get_menu_prototype(caller) + tags = prototype.get("tags", []) + try: + inp = [tup[0] for tup in tags].index(tagname) + return tags[inp] + except ValueError: + return None + + def _display_tag(tag_tuple): - """Pretty-print attribute tuple""" + """Pretty-print tag tuple""" tagkey, category, data = tag_tuple out = ("Tag: '{tagkey}' (category: {category}{dat})".format( tagkey=tagkey, category=category, dat=", data: {}".format(data) if data else "")) return out -def _add_tag(caller, tag, **kwargs): +def _add_tag(caller, tag_string, **kwargs): """ - Add tags to the system, parsing this syntax: - tagname - tagname;category - tagname;category;data + Add tags to the system, parsing input + + Args: + caller (Object): Caller of menu. + tag_string (str): Input from user on one of these forms + tagname + tagname;category + tagname;category;data + + Kwargs: + delete (str): If this is set, tag_string is considered + the name of the tag to delete. + + Returns: + result (str): Result string of action. """ - - tag = tag.strip().lower() + tag = tag_string.strip().lower() category = None data = "" - tagtuple = tag.split(";", 2) - ntuple = len(tagtuple) + if 'delete' in kwargs: + tag = tag_string.lower().strip() + else: + nameparts = tag.split(";", 2) + ntuple = len(nameparts) + if ntuple == 2: + tag, category = nameparts + elif ntuple > 2: + tag, category, data = nameparts[:3] - if ntuple == 2: - tag, category = tagtuple - elif ntuple > 2: - tag, category, data = tagtuple - - tag_tuple = (tag, category, data) + tag_tuple = (tag.lower(), category.lower() if category else None, data) if tag: prot = _get_menu_prototype(caller) tags = prot.get('tags', []) - old_tag = kwargs.get("edit", None) + old_tag = _get_tup_by_tagname(caller, tag) - if not old_tag: + if 'delete' in kwargs: + + if old_tag: + tags.pop(tags.index(old_tag)) + text = "Removed tag '{}'".format(tag) + else: + text = "Found no tag to remove." + elif not old_tag: # a fresh, new tag tags.append(tag_tuple) + text = "Added Tag '{}'".format(tag) else: - # old tag exists; editing a tag means removing the old and replacing with new - try: - ind = [tup[0] for tup in tags].index(old_tag) - del tags[ind] - if tags: - tags.insert(ind, tag_tuple) - else: - tags = [tag_tuple] - except IndexError: - pass + # old tag exists; editing a tag means replacing old with new + ind = tags.index(old_tag) + tags[ind] = tag_tuple + text = "Edited Tag '{}'".format(tag) _set_prototype_value(caller, "tags", tags) - - text = kwargs.get('text') - if not text: - if 'edit' in kwargs: - text = "Edited " + _display_tag(tag_tuple) - else: - text = "Added " + _display_tag(tag_tuple) else: - text = "Tag must be given as 'tag[;category;data]." + text = "Tag must be given as 'tag[;category;data]'." - options = {"key": "_default", - "goto": lambda caller: None} - return text, options + return text -def _edit_tag(caller, old_tag, new_tag, **kwargs): - return _add_tag(caller, new_tag, edit=old_tag) +def _tag_select(caller, tagname): + tag_tup = _get_tup_by_tagname(caller, tagname) + if tag_tup: + return "node_examine_entity", \ + {"text": _display_tag(tag_tup), "back": "attrs"} + else: + caller.msg("Tag not found.") + return "node_attrs" -@list_node(_caller_tags) +def _tags_actions(caller, raw_inp, **kwargs): + """Parse actions for tags listing""" + choices = kwargs.get("available_choices", []) + tagname, action = _default_parse( + raw_inp, choices, ('examine', 'e'), ('remove', 'r', 'delete', 'd')) + + if tagname is None: + tagname = raw_inp.lower().strip() + + tag_tup = _get_tup_by_tagname(caller, tagname) + + if tag_tup: + if action == 'examine': + return "node_examine_entity", \ + {"text": _display_tag(tag_tup), 'back': 'tags'} + elif action == 'remove': + res = _add_tag(caller, tagname, delete=True) + caller.msg(res) + else: + res = _add_tag(caller, raw_inp) + caller.msg(res) + return "node_tags" + + +@list_node(_caller_tags, _tag_select) def node_tags(caller): text = """ |cTags|n are used to group objects so they can quickly be found later. Enter tags on one of @@ -1071,8 +1116,8 @@ def node_tags(caller): tagname;category tagname;category;data - {current} - """.format(current=_get_current_value(caller, 'tags')) + {actions} + """.format(actions=_format_list_actions("examine", "remove", prefix="Actions: ")) helptext = """ Tags are shared between all objects with that tag. So the 'data' field (which is not @@ -1082,10 +1127,12 @@ def node_tags(caller): All objects created with this prototype will automatically get assigned a tag named the same as the |cprototype_key|n and with a category "{tag_category}". This allows the spawner to optionally update previously spawned objects when their prototype changes. - """.format(protlib._PROTOTYPE_TAG_CATEGORY) + """.format(tag_category=protlib._PROTOTYPE_TAG_CATEGORY) text = (text, helptext) options = _wizard_options("tags", "attrs", "locks") + options.append({"key": "_default", + "goto": _tags_actions}) return text, options diff --git a/evennia/prototypes/tests.py b/evennia/prototypes/tests.py index 4b16ad9ab2..299495628e 100644 --- a/evennia/prototypes/tests.py +++ b/evennia/prototypes/tests.py @@ -427,8 +427,6 @@ class TestMenuModule(EvenniaTest): with mock.patch("evennia.utils.utils.get_all_typeclasses", new=mock.MagicMock(return_value={"foo": None, "bar": None})): self.assertEqual(olc_menus._all_typeclasses(caller), ["bar", "foo"]) - self.assertTrue(olc_menus._typeclass_examine( - caller, "evennia.objects.objects.DefaultObject").startswith("Typeclass |y")) self.assertEqual(olc_menus._typeclass_select( caller, "evennia.objects.objects.DefaultObject"), "node_key") @@ -441,34 +439,35 @@ class TestMenuModule(EvenniaTest): # attr helpers self.assertEqual(olc_menus._caller_attrs(caller), []) - self.assertEqual(olc_menus._add_attr(caller, "test1=foo1"), (Something, {"key": "_default", "goto": Something})) - self.assertEqual(olc_menus._add_attr(caller, "test2;cat1=foo2"), (Something, {"key": "_default", "goto": Something})) - self.assertEqual(olc_menus._add_attr(caller, "test3;cat2;edit:false()=foo3"), (Something, {"key": "_default", "goto": Something})) - self.assertEqual(olc_menus._add_attr(caller, "test4;cat3;set:true();edit:false()=foo4"), (Something, {"key": "_default", "goto": Something})) - self.assertEqual(olc_menus._add_attr(caller, "test5;cat4;set:true();edit:false()=123"), (Something, {"key": "_default", "goto": Something})) - self.assertEqual(olc_menus._caller_attrs( - caller), + self.assertEqual(olc_menus._add_attr(caller, "test1=foo1"), Something) + self.assertEqual(olc_menus._add_attr(caller, "test2;cat1=foo2"), Something) + self.assertEqual(olc_menus._add_attr(caller, "test3;cat2;edit:false()=foo3"), Something) + self.assertEqual(olc_menus._add_attr(caller, "test4;cat3;set:true();edit:false()=foo4"), Something) + self.assertEqual(olc_menus._add_attr(caller, "test5;cat4;set:true();edit:false()=123"), Something) + self.assertEqual(olc_menus._get_menu_prototype(caller)['attrs'], [("test1", "foo1", None, ''), ("test2", "foo2", "cat1", ''), ("test3", "foo3", "cat2", "edit:false()"), ("test4", "foo4", "cat3", "set:true();edit:false()"), ("test5", '123', "cat4", "set:true();edit:false()")]) - self.assertEqual(olc_menus._edit_attr(caller, "test1", "1;cat5;edit:all()"), (Something, {"key": "_default", "goto": Something})) - self.assertEqual(olc_menus._examine_attr(caller, "test1"), Something) # tag helpers self.assertEqual(olc_menus._caller_tags(caller), []) - self.assertEqual(olc_menus._add_tag(caller, "foo1"), (Something, {"key": "_default", "goto": Something})) - self.assertEqual(olc_menus._add_tag(caller, "foo2;cat1"), (Something, {"key": "_default", "goto": Something})) - self.assertEqual(olc_menus._add_tag(caller, "foo3;cat2;dat1"), (Something, {"key": "_default", "goto": Something})) - self.assertEqual(olc_menus._caller_tags( - caller), + self.assertEqual(olc_menus._add_tag(caller, "foo1"), Something) + self.assertEqual(olc_menus._add_tag(caller, "foo2;cat1"), Something) + self.assertEqual(olc_menus._add_tag(caller, "foo3;cat2;dat1"), Something) + self.assertEqual(olc_menus._caller_tags(caller), ['foo1', 'foo2', 'foo3']) + self.assertEqual(olc_menus._get_menu_prototype(caller)['tags'], [('foo1', None, ""), ('foo2', 'cat1', ""), ('foo3', 'cat2', "dat1")]) - self.assertEqual(olc_menus._edit_tag(caller, "foo1", "bar1;cat1"), (Something, {"key": "_default", "goto": Something})) - self.assertEqual(olc_menus._display_tag(olc_menus._caller_tags(caller)[0]), Something) - self.assertEqual(olc_menus._caller_tags(caller)[0], ("bar1", "cat1", "")) + self.assertEqual(olc_menus._add_tag(caller, "foo1", delete=True), "Removed tag 'foo1'") + self.assertEqual(olc_menus._get_menu_prototype(caller)['tags'], + [('foo2', 'cat1', ""), + ('foo3', 'cat2', "dat1")]) + + self.assertEqual(olc_menus._display_tag(olc_menus._get_menu_prototype(caller)['tags'][0]), Something) + self.assertEqual(olc_menus._caller_tags(caller), ["foo2", "foo3"]) protlib.save_prototype(**self.test_prot) From 6e7986a915c1329ddb7b8c9a7cc5e66348a848bd Mon Sep 17 00:00:00 2001 From: Griatch Date: Fri, 27 Jul 2018 18:46:30 +0200 Subject: [PATCH 180/208] Refactor locks and permissions in olc menu --- evennia/prototypes/menus.py | 185 +++++++++++++++++++++++++++++++----- evennia/prototypes/tests.py | 14 ++- 2 files changed, 173 insertions(+), 26 deletions(-) diff --git a/evennia/prototypes/menus.py b/evennia/prototypes/menus.py index e5ff1179a2..138f97cf13 100644 --- a/evennia/prototypes/menus.py +++ b/evennia/prototypes/menus.py @@ -236,11 +236,13 @@ def _format_protfuncs(): def _format_lockfuncs(): out = [] sorted_funcs = [(key, func) for key, func in - sorted(get_all_lockfuncs(), key=lambda tup: tup[0])] + sorted(get_all_lockfuncs().items(), key=lambda tup: tup[0])] for lockfunc_name, lockfunc in sorted_funcs: + doc = (lockfunc.__doc__ or "").strip() out.append("- |c${name}|n - |W{docs}".format( name=lockfunc_name, - docs=utils.justify(lockfunc.__doc__.strip(), align='l', indent=10).strip())) + docs=utils.justify(doc, align='l', indent=10).strip())) + return "\n".join(out) def _format_list_actions(*args, **kwargs): @@ -769,7 +771,7 @@ def _aliases_actions(caller, raw_inp, **kwargs): else: # if not a valid remove, add as a new alias alias = raw_inp.lower().strip() - if alias not in aliases: + if alias and alias not in aliases: aliases.append(alias) _set_prototype_value(caller, "aliases", aliases) caller.msg("Added alias '{}'.".format(alias)) @@ -786,7 +788,7 @@ def node_aliases(caller): case sensitive. {actions} - """.format(_format_list_actions("remove", prefix="|w|W to add new alias. Other action: ")) + """.format(actions=_format_list_actions("remove", prefix="|w|W to add new alias. Other action: ")) helptext = """ Aliases are fixed alternative identifiers and are stored with the new object. @@ -1053,9 +1055,9 @@ def _add_tag(caller, tag_string, **kwargs): if old_tag: tags.pop(tags.index(old_tag)) - text = "Removed tag '{}'".format(tag) + text = "Removed Tag '{}'.".format(tag) else: - text = "Found no tag to remove." + text = "Found no Tag to remove." elif not old_tag: # a fresh, new tag tags.append(tag_tuple) @@ -1138,7 +1140,80 @@ def node_tags(caller): # locks node +def _caller_locks(caller): + locks = _get_menu_prototype(caller).get("locks", "") + return [lck for lck in locks.split(";") if lck] + +def _locks_display(caller, lock): + try: + locktype, lockdef = lock.split(":", 1) + except ValueError: + txt = "Malformed lock string - Missing ':'" + else: + txt = ("{lockstr}\n\n" + "|WLocktype: |w{locktype}|n\n" + "|WLock def: |w{lockdef}|n\n").format( + lockstr=lock, + locktype=locktype, + lockdef=lockdef) + return txt + + +def _lock_select(caller, lockstr): + return "node_examine_entity", {"text": _locks_display(caller, lockstr), "back": "locks"} + + +def _lock_add(caller, lock, **kwargs): + locks = _caller_locks(caller) + + try: + locktype, lockdef = lock.split(":", 1) + except ValueError: + return "Lockstring lacks ':'." + + locktype = locktype.strip().lower() + + if 'delete' in kwargs: + try: + ind = locks.index(lock) + locks.pop(ind) + _set_prototype_value(caller, "locks", ";".join(locks), parse=False) + ret = "Lock {} deleted.".format(lock) + except ValueError: + ret = "No lock found to delete." + return ret + try: + locktypes = [lck.split(":", 1)[0].strip().lower() for lck in locks] + ind = locktypes.index(locktype) + locks[ind] = lock + ret = "Lock with locktype '{}' updated.".format(locktype) + except ValueError: + locks.append(lock) + ret = "Added lock '{}'.".format(lock) + _set_prototype_value(caller, "locks", ";".join(locks)) + return ret + + +def _locks_actions(caller, raw_inp, **kwargs): + choices = kwargs.get("available_choices", []) + lock, action = _default_parse( + raw_inp, choices, ("examine", "e"), ("remove", "r", "delete", "d")) + + if lock: + if action == 'examine': + return "node_examine_entity", {"text": _locks_display(caller, lock), "back": "locks"} + elif action == 'remove': + ret = _lock_add(caller, lock, delete=True) + caller.msg(ret) + else: + ret = _lock_add(caller, raw_inp) + caller.msg(ret) + + return "node_locks" + + +@list_node(_caller_locks, _lock_select) def node_locks(caller): text = """ @@ -1148,22 +1223,21 @@ def node_locks(caller): locktype:[NOT] lockfunc(args) locktype: [NOT] lockfunc(args) [AND|OR|NOT] lockfunc(args) [AND|OR|NOT] ... - Separate multiple lockstrings by semicolons (;). - - {current} - """.format(current=_get_current_value(caller, 'locks')) + {action} + """.format(action=_format_list_actions("examine", "remove", prefix="Actions: ")) helptext = """ - Here is an example of a lock string constisting of two locks: + Here is an example of two lock strings: - edit:false();call:tag(Foo) OR perm(Builder) + edit:false() + call:tag(Foo) OR perm(Builder) Above locks limit two things, 'edit' and 'call'. Which lock types are actually checked depend on the typeclass of the object being spawned. Here 'edit' is never allowed by anyone while 'call' is allowed to all accessors with a |ctag|n 'Foo' OR which has the |cPermission|n 'Builder'. - |c$lockfuncs|n + |cAvailable lockfuncs:|n {lfuncs} """.format(lfuncs=_format_lockfuncs()) @@ -1172,24 +1246,87 @@ def node_locks(caller): options = _wizard_options("locks", "tags", "permissions") options.append({"key": "_default", - "goto": (_set_property, - dict(prop="locks", - processor=lambda s: s.strip(), - next_node="node_permissions"))}) + "goto": _locks_actions}) + return text, options # permissions node +def _caller_permissions(caller): + prototype = _get_menu_prototype(caller) + perms = prototype.get("permissions", []) + return perms + +def _display_perm(caller, permission): + hierarchy = settings.PERMISSION_HIERARCHY + perm_low = permission.lower() + if perm_low in [prm.lower() for prm in hierarchy]: + txt = "Permission (in hieararchy): {}".format( + ", ".join( + ["|w[{}]|n".format(prm) + if prm.lower() == perm_low else "|W{}|n".format(prm) + for prm in hierarchy])) + else: + txt = "Permission: '{}'".format(permission) + return txt + + +def _permission_select(caller, permission, **kwargs): + return "node_examine_entity", {"text": _display_perm(caller, permission), "back": "permissions"} + + +def _add_perm(caller, perm, **kwargs): + if perm: + perm_low = perm.lower() + perms = _caller_permissions(caller) + perms_low = [prm.lower() for prm in perms] + if 'delete' in kwargs: + try: + ind = perms_low.index(perm_low) + del perms[ind] + text = "Removed Permission '{}'.".format(perm) + except ValueError: + text = "Found no Permission to remove." + else: + if perm_low in perms_low: + text = "Permission already set." + else: + perms.append(perm) + _set_prototype_value(caller, "permissions", perms) + text = "Added Permission '{}'".format(perm) + return text + + +def _permissions_actions(caller, raw_inp, **kwargs): + """Parse actions for permission listing""" + choices = kwargs.get("available_choices", []) + perm, action = _default_parse( + raw_inp, choices, ('examine', 'e'), ('remove', 'r', 'delete', 'd')) + + if perm: + if action == 'examine': + return "node_examine_entity", \ + {"text": _display_perm(caller, perm), "back": "permissions"} + elif action == 'remove': + res = _add_perm(caller, perm, delete=True) + caller.msg(res) + else: + res = _add_perm(caller, raw_inp.strip()) + caller.msg(res) + return "node_permissions" + + +@list_node(_caller_permissions, _permission_select) def node_permissions(caller): text = """ |cPermissions|n are simple strings used to grant access to this object. A permission is used when a |clock|n is checked that contains the |wperm|n or |wpperm|n lock functions. - {current} - """.format(current=_get_current_value(caller, "permissions")) + {actions} + """.format(actions=_format_list_actions("examine", "remove"), prefix="Actions: ") helptext = """ Any string can act as a permission as long as a lock is set to look for it. Depending on the @@ -1201,16 +1338,14 @@ def node_permissions(caller): For example, a |clock|n string like "edit:perm(Builder)" will grant access to accessors having the |cpermission|n "Builder" or higher. - """.format(settings.PERMISSION_HIERARCHY) + """.format(permissions=", ".join(settings.PERMISSION_HIERARCHY)) text = (text, helptext) - options = _wizard_options("permissions", "destination", "location") + options = _wizard_options("permissions", "locks", "location") options.append({"key": "_default", - "goto": (_set_property, - dict(prop="permissions", - processor=lambda s: [part.strip() for part in s.split(",")], - next_node="node_location"))}) + "goto": _permissions_actions}) + return text, options diff --git a/evennia/prototypes/tests.py b/evennia/prototypes/tests.py index 299495628e..92b8e85a65 100644 --- a/evennia/prototypes/tests.py +++ b/evennia/prototypes/tests.py @@ -461,7 +461,7 @@ class TestMenuModule(EvenniaTest): [('foo1', None, ""), ('foo2', 'cat1', ""), ('foo3', 'cat2', "dat1")]) - self.assertEqual(olc_menus._add_tag(caller, "foo1", delete=True), "Removed tag 'foo1'") + self.assertEqual(olc_menus._add_tag(caller, "foo1", delete=True), "Removed Tag 'foo1'.") self.assertEqual(olc_menus._get_menu_prototype(caller)['tags'], [('foo2', 'cat1', ""), ('foo3', 'cat2', "dat1")]) @@ -471,6 +471,18 @@ class TestMenuModule(EvenniaTest): protlib.save_prototype(**self.test_prot) + # locks helpers + self.assertEqual(olc_menus._lock_add(caller, "foo:false()"), "Added lock 'foo:false()'.") + self.assertEqual(olc_menus._lock_add(caller, "foo2:false()"), "Added lock 'foo2:false()'.") + self.assertEqual(olc_menus._lock_add(caller, "foo2:true()"), "Lock with locktype 'foo2' updated.") + self.assertEqual(olc_menus._get_menu_prototype(caller)["locks"], "foo:false();foo2:true()") + + # perm helpers + self.assertEqual(olc_menus._add_perm(caller, "foo"), "Added Permission 'foo'") + self.assertEqual(olc_menus._add_perm(caller, "foo2"), "Added Permission 'foo2'") + self.assertEqual(olc_menus._get_menu_prototype(caller)["permissions"], ["foo", "foo2"]) + + # spawn helpers with mock.patch("evennia.prototypes.menus.protlib.search_prototype", new=mock.MagicMock(return_value=[_PROTPARENTS['GOBLIN']])): From 23e2c0e34f03d3134794e0762c648ed100f08841 Mon Sep 17 00:00:00 2001 From: Griatch Date: Sat, 28 Jul 2018 18:26:00 +0200 Subject: [PATCH 181/208] Add search-object functionality to olc menu --- evennia/prototypes/menus.py | 233 +++++++++++++++++++++++++------ evennia/prototypes/prototypes.py | 79 +++++++++-- evennia/prototypes/spawner.py | 13 +- evennia/utils/evmenu.py | 7 +- 4 files changed, 266 insertions(+), 66 deletions(-) diff --git a/evennia/prototypes/menus.py b/evennia/prototypes/menus.py index 138f97cf13..ab66363c71 100644 --- a/evennia/prototypes/menus.py +++ b/evennia/prototypes/menus.py @@ -7,7 +7,9 @@ OLC Prototype menu nodes import json import re from random import choice +from django.db.models import Q from django.conf import settings +from evennia.objects.models import ObjectDB from evennia.utils.evmenu import EvMenu, list_node from evennia.utils import evmore from evennia.utils.ansi import strip_ansi @@ -273,7 +275,11 @@ def _get_current_value(caller, keyname, formatter=str): flat_prot = _get_flat_menu_prototype(caller) if keyname in flat_prot: # value in flattened prot - return "Current {} (|binherited|n): {}".format(keyname, formatter(flat_prot[keyname])) + if keyname == 'prototype_key': + # we don't inherit prototype_keys + return "[No prototype_key set] (|rnot inherited|n)" + else: + return "Current {} (|binherited|n): {}".format(keyname, formatter(flat_prot[keyname])) return "[No {} set]".format(keyname) @@ -305,6 +311,180 @@ def _default_parse(raw_inp, choices, *args): # Menu nodes ------------------------------ +# helper nodes + +# validate prototype (available as option from all nodes) + +def node_validate_prototype(caller, raw_string, **kwargs): + """General node to view and validate a protototype""" + prototype = _get_flat_menu_prototype(caller, validate=False) + prev_node = kwargs.get("back", "index") + + _, text = _validate_prototype(prototype) + + helptext = """ + The validator checks if the prototype's various values are on the expected form. It also tests + any $protfuncs. + + """ + + text = (text, helptext) + + options = _wizard_options(None, prev_node, None) + options.append({"key": "_default", + "goto": "node_" + prev_node}) + + return text, options + + +def node_examine_entity(caller, raw_string, **kwargs): + """ + General node to view a text and then return to previous node. Kwargs should contain "text" for + the text to show and 'back" pointing to the node to return to. + """ + text = kwargs.get("text", "Nothing was found here.") + helptext = "Use |wback|n to return to the previous node." + prev_node = kwargs.get('back', 'index') + + text = (text, helptext) + + options = _wizard_options(None, prev_node, None) + options.append({"key": "_default", + "goto": "node_" + prev_node}) + + return text, options + + +def _search_object(caller): + "update search term based on query stored on menu; store match too" + try: + searchstring = caller.ndb._menutree.olc_search_object_term.strip() + caller.ndb._menutree.olc_search_object_matches = [] + except AttributeError: + return [] + + if not searchstring: + caller.msg("Must specify a search criterion.") + return [] + + is_dbref = utils.dbref(searchstring) + is_account = searchstring.startswith("*") + + if is_dbref or is_account: + + if is_dbref: + # a dbref search + results = caller.search(searchstring, global_search=True, quiet=True) + else: + # an account search + searchstring = searchstring.lstrip("*") + results = caller.search_account(searchstring, quiet=True) + else: + keyquery = Q(db_key__istartswith=searchstring) + aliasquery = Q(db_tags__db_key__istartswith=searchstring, + db_tags__db_tagtype__iexact="alias") + results = ObjectDB.objects.filter(keyquery | aliasquery).distinct() + + caller.msg("Searching for '{}' ...".format(searchstring)) + caller.ndb._menutree.olc_search_object_matches = results + return ["{}(#{})".format(obj.key, obj.id) for obj in results] + + +def _object_select(caller, obj_entry, **kwargs): + choices = kwargs['available_choices'] + num = choices.index(obj_entry) + matches = caller.ndb._menutree.olc_search_object_matches + obj = matches[num] + + if not obj.access(caller, 'examine'): + caller.msg("|rYou don't have 'examine' access on this object.|n") + del caller.ndb._menutree.olc_search_object_term + return "node_search_object" + + prot = spawner.prototype_from_object(obj) + txt = protlib.prototype_to_str(prot) + return "node_examine_entity", {"text": txt, "back": "search_object"} + + +def _object_actions(caller, raw_inp, **kwargs): + "All this does is to queue a search query" + choices = kwargs['available_choices'] + obj_entry, action = _default_parse( + raw_inp, choices, ("examine", "e"), ("create prototype from object", "create", "c")) + + if obj_entry: + + num = choices.index(obj_entry) + matches = caller.ndb._menutree.olc_search_object_matches + obj = matches[num] + prot = spawner.prototype_from_object(obj) + + if action == "examine": + + if not obj.access(caller, 'examine'): + caller.msg("\n|rYou don't have 'examine' access on this object.|n") + del caller.ndb._menutree.olc_search_object_term + return "node_search_object" + + txt = protlib.prototype_to_str(prot) + return "node_examine_entity", {"text": txt, "back": "search_object"} + else: + # load prototype + + if not obj.access(caller, 'control'): + caller.msg("|rYou don't have access to do this with this object.|n") + del caller.ndb._menutree.olc_search_object_term + return "node_search_object" + + _set_menu_prototype(caller, prot) + caller.msg("Created prototype from object.") + return "node_index" + else: + caller.ndb._menutree.olc_search_object_term = raw_inp + return "node_search_object", kwargs + + +@list_node(_search_object, _object_select) +def node_search_object(caller, raw_inp, **kwargs): + """ + Node for searching for an existing object. + """ + try: + matches = caller.ndb._menutree.olc_search_object_matches + except AttributeError: + matches = [] + nmatches = len(matches) + prev_node = kwargs.get("back", "index") + + if matches: + text = """ + Found {num} match{post}. + + {actions} + (|RWarning: creating a prototype will |roverwrite|r |Rthe current prototype!)|n""".format( + num=nmatches, post="es" if nmatches > 1 else "", + actions=_format_list_actions( + "examine", "create prototype from object", prefix="Actions: ")) + else: + text = "Enter search criterion." + + helptext = """ + You can search objects by specifying partial key, alias or its exact #dbref. Use *query to + search for an Account instead. + + Once having found any matches you can choose to examine it or use |ccreate prototype from + object|n. If doing the latter, a prototype will be calculated from the selected object and + loaded as the new 'current' prototype. This is useful for having a base to build from but be + careful you are not throwing away any existing, unsaved, prototype work! + """ + + text = (text, helptext) + + options = _wizard_options(None, prev_node, None) + options.append({"key": "_default", + "goto": (_object_actions, {"back": prev_node})}) + + return text, options # main index (start page) node @@ -382,49 +562,9 @@ def node_index(caller): {"key": ("|wSP|Wawn prototype", "spawn", "sp"), "goto": "node_prototype_spawn"}, {"key": ("|wLO|Wad prototype", "load", "lo"), - "goto": "node_prototype_load"})) - - return text, options - - -# validate prototype (available as option from all nodes) - -def node_validate_prototype(caller, raw_string, **kwargs): - """General node to view and validate a protototype""" - prototype = _get_menu_prototype(caller) - prev_node = kwargs.get("back", "index") - - _, text = _validate_prototype(prototype) - - helptext = """ - The validator checks if the prototype's various values are on the expected form. It also tests - any $protfuncs. - - """ - - text = (text, helptext) - - options = _wizard_options(None, prev_node, None) - options.append({"key": "_default", - "goto": "node_" + prev_node}) - - return text, options - - -def node_examine_entity(caller, raw_string, **kwargs): - """ - General node to view a text and then return to previous node. Kwargs should contain "text" for - the text to show and 'back" pointing to the node to return to. - """ - text = kwargs.get("text", "Nothing was found here.") - helptext = "Use |wback|n to return to the previous node." - prev_node = kwargs.get('back', 'index') - - text = (text, helptext) - - options = _wizard_options(None, prev_node, None) - options.append({"key": "_default", - "goto": "node_" + prev_node}) + "goto": "node_prototype_load"}, + {"key": ("|wSE|Warch objects|n", "search", "se"), + "goto": "node_search_object"})) return text, options @@ -811,7 +951,7 @@ def node_aliases(caller): def _caller_attrs(caller): prototype = _get_menu_prototype(caller) - attrs = ["{}={}".format(tup[0], utils.crop(utils.to_str(tup[1]), width=10)) + attrs = ["{}={}".format(tup[0], utils.crop(utils.to_str(tup[1], force_string=True), width=10)) for tup in prototype.get("attrs", [])] return attrs @@ -1837,7 +1977,7 @@ class OLCMenu(EvMenu): """ olc_keys = ("index", "forward", "back", "previous", "next", "validate prototype", - "save prototype", "load prototype", "spawn prototype") + "save prototype", "load prototype", "spawn prototype", "search objects") olc_options = [] other_options = [] for key, desc in optionlist: @@ -1878,6 +2018,7 @@ def start_olc(caller, session=None, prototype=None): menudata = {"node_index": node_index, "node_validate_prototype": node_validate_prototype, "node_examine_entity": node_examine_entity, + "node_search_object": node_search_object, "node_prototype_key": node_prototype_key, "node_prototype_parent": node_prototype_parent, "node_typeclass": node_typeclass, diff --git a/evennia/prototypes/prototypes.py b/evennia/prototypes/prototypes.py index 4c0a2d3186..8ce20d5311 100644 --- a/evennia/prototypes/prototypes.py +++ b/evennia/prototypes/prototypes.py @@ -150,7 +150,8 @@ def value_to_obj_or_any(value): stype = type(value) if is_iter(value): if stype == dict: - return {value_to_obj_or_any(key): value_to_obj_or_any(val) for key, val in value.items()} + return {value_to_obj_or_any(key): + value_to_obj_or_any(val) for key, val in value.items()} else: return stype([value_to_obj_or_any(val) for val in value]) obj = dbid_to_obj(value, ObjectDB) @@ -165,18 +166,70 @@ def prototype_to_str(prototype): prototype (dict): The prototype. """ - header = ( - "|cprototype key:|n {}, |ctags:|n {}, |clocks:|n {} \n" - "|cdesc:|n {} \n|cprototype:|n ".format( - prototype.get('prototype_key', None), - ", ".join(prototype.get('prototype_tags', ['None'])), - prototype.get('prototype_locks', None), - prototype.get('prototype_desc', None))) - proto = ("{{\n {} \n}}".format( - "\n ".join( - "{!r}: {!r},".format(key, value) for key, value in - sorted(prototype.items()) if key not in _PROTOTYPE_META_NAMES)).rstrip(",")) - return header + proto + header = """ +|cprototype-key:|n {prototype_key}, |c-tags:|n {prototype_tags}, |c-locks:|n {prototype_locks}|n +|c-desc|n: {prototype_desc} +|cprototype-parent:|n {prototype_parent} + \n""".format( + prototype_key=prototype.get('prototype_key', '|r[UNSET](required)|n'), + prototype_tags=prototype.get('prototype_tags', '|wNone|n'), + prototype_locks=prototype.get('prototype_locks', '|wNone|n'), + prototype_desc=prototype.get('prototype_desc', '|wNone|n'), + prototype_parent=prototype.get('prototype_parent', '|wNone|n')) + + key = prototype.get('key', '') + if key: + key = "|ckey:|n {key}".format(key=key) + aliases = prototype.get("aliases", '') + if aliases: + aliases = "|caliases:|n {aliases}".format( + aliases=", ".join(aliases)) + attrs = prototype.get("attrs", '') + if attrs: + out = [] + for (attrkey, value, category, locks) in attrs: + locks = ", ".join(lock for lock in locks if lock) + category = "|ccategory:|n {}".format(category) if category else '' + cat_locks = "" + if category or locks: + cat_locks = "(|ccategory:|n {category}, ".format( + category=category if category else "|wNone|n") + out.append( + "{attrkey} " + "{cat_locks}\n" + " |c=|n {value}".format( + attrkey=attrkey, + cat_locks=cat_locks, + locks=locks if locks else "|wNone|n", + value=value)) + attrs = "|cattrs:|n\n {attrs}".format(attrs="\n ".join(out)) + tags = prototype.get('tags', '') + if tags: + out = [] + for (tagkey, category, data) in tags: + out.append("{tagkey} (category: {category}{dat})".format( + tagkey=tagkey, category=category, dat=", data: {}".format(data) if data else "")) + tags = "|ctags:|n\n {tags}".format(tags="\n ".join(out)) + locks = prototype.get('locks', '') + if locks: + locks = "|clocks:|n\n {locks}".format(locks="\n ".join(locks.split(";"))) + permissions = prototype.get("permissions", '') + if permissions: + permissions = "|cpermissions:|n {perms}".format(perms=", ".join(permissions)) + location = prototype.get("location", '') + if location: + location = "|clocation:|n {location}".format(location=location) + home = prototype.get("home", '') + if home: + home = "|chome:|n {home}".format(home=home) + destination = prototype.get("destination", '') + if destination: + destination = "|cdestination:|n {destination}".format(destination=destination) + + body = "\n".join(part for part in (key, aliases, attrs, tags, locks, permissions, + location, home, destination) if part) + + return header.lstrip() + body.strip() def check_permission(prototype_key, action, default=True): diff --git a/evennia/prototypes/spawner.py b/evennia/prototypes/spawner.py index 3dd8e11d67..1bae219368 100644 --- a/evennia/prototypes/spawner.py +++ b/evennia/prototypes/spawner.py @@ -150,6 +150,8 @@ def _get_prototype(dic, prot, protparents): for infinite recursion here. """ + # we don't overload the prototype_key + prototype_key = prot.get('prototype_key', None) if "prototype_parent" in dic: # move backwards through the inheritance for prototype in make_iter(dic["prototype_parent"]): @@ -157,6 +159,7 @@ def _get_prototype(dic, prot, protparents): new_prot = _get_prototype(protparents.get(prototype.lower(), {}), prot, protparents) prot.update(new_prot) prot.update(dic) + prot['prototype_key'] = prototype_key prot.pop("prototype_parent", None) # we don't need this anymore return prot @@ -217,19 +220,19 @@ def prototype_from_object(obj): location = obj.db_location if location: - prot['location'] = location + prot['location'] = location.dbref home = obj.db_home if home: - prot['home'] = home + prot['home'] = home.dbref destination = obj.db_destination if destination: - prot['destination'] = destination + prot['destination'] = destination.dbref locks = obj.locks.all() if locks: - prot['locks'] = locks + prot['locks'] = ";".join(locks) perms = obj.permissions.get() if perms: - prot['permissions'] = perms + prot['permissions'] = make_iter(perms) aliases = obj.aliases.get() if aliases: prot['aliases'] = aliases diff --git a/evennia/utils/evmenu.py b/evennia/utils/evmenu.py index d21aec2c56..a2c7429d0d 100644 --- a/evennia/utils/evmenu.py +++ b/evennia/utils/evmenu.py @@ -1055,7 +1055,10 @@ def list_node(option_generator, select=None, pagesize=10): else: if callable(select): try: - return select(caller, selection) + if bool(getargspec(select).keywords): + return select(caller, selection, available_choices=available_choices) + else: + return select(caller, selection) except Exception: logger.log_trace() elif select: @@ -1124,7 +1127,7 @@ def list_node(option_generator, select=None, pagesize=10): except Exception: logger.log_trace() else: - if isinstance(decorated_options, {}): + if isinstance(decorated_options, dict): decorated_options = [decorated_options] else: decorated_options = make_iter(decorated_options) From 0a73c731570151e1344844563610fcdd075327c5 Mon Sep 17 00:00:00 2001 From: Griatch Date: Sat, 28 Jul 2018 19:58:20 +0200 Subject: [PATCH 182/208] Complete refactoring of main nodes. Remain spawn/load/save --- evennia/prototypes/menus.py | 216 +++++++++++++++++++++++++++++------- evennia/prototypes/tests.py | 4 + evennia/utils/evmenu.py | 11 +- 3 files changed, 191 insertions(+), 40 deletions(-) diff --git a/evennia/prototypes/menus.py b/evennia/prototypes/menus.py index ab66363c71..9024223c31 100644 --- a/evennia/prototypes/menus.py +++ b/evennia/prototypes/menus.py @@ -174,7 +174,7 @@ def _set_property(caller, raw_string, **kwargs): return next_node -def _wizard_options(curr_node, prev_node, next_node, color="|W"): +def _wizard_options(curr_node, prev_node, next_node, color="|W", search=False): """Creates default navigation options available in the wizard.""" options = [] if prev_node: @@ -195,6 +195,9 @@ def _wizard_options(curr_node, prev_node, next_node, color="|W"): if curr_node: options.append({"key": ("|wV|Walidate prototype", "validate", "v"), "goto": ("node_validate_prototype", {"back": curr_node})}) + if search: + options.append({"key": ("|wSE|Warch objects", "search object", "search", "se"), + "goto": ("node_search_object", {"back": curr_node})}) return options @@ -1495,10 +1498,11 @@ def node_permissions(caller): def node_location(caller): text = """ - The |cLocation|n of this object in the world. If not given, the object will spawn - in the inventory of |c{caller}|n instead. + The |cLocation|n of this object in the world. If not given, the object will spawn in the + inventory of |c{caller}|n by default. {current} + """.format(caller=caller.key, current=_get_current_value(caller, "location")) helptext = """ @@ -1508,11 +1512,11 @@ def node_location(caller): |c$protfuncs|n {pfuncs} - """.format(pfuncs=_format_protfuncs) + """.format(pfuncs=_format_protfuncs()) text = (text, helptext) - options = _wizard_options("location", "permissions", "home") + options = _wizard_options("location", "permissions", "home", search=True) options.append({"key": "_default", "goto": (_set_property, dict(prop="location", @@ -1529,20 +1533,27 @@ def node_home(caller): text = """ The |cHome|n location of an object is often only used as a backup - this is where the object will be moved to if its location is deleted. The home location can also be used as an actual - home for characters to quickly move back to. If unset, the global home default will be used. + home for characters to quickly move back to. + + If unset, the global home default (|w{default}|n) will be used. {current} - """.format(current=_get_current_value(caller, "home")) + """.format(default=settings.DEFAULT_HOME, + current=_get_current_value(caller, "home")) helptext = """ - The location can be specified as as #dbref but can also be explicitly searched for using - $obj(name). + The home can be given as a #dbref but can also be specified using the protfunc + '$obj(name)'. Use |wSE|nearch to find objects in the database. - The home location is often not used except as a backup. It should never be unset. - """ + The home location is commonly not used except as a backup; using the global default is often + enough. + + |c$protfuncs|n + {pfuncs} + """.format(pfuncs=_format_protfuncs()) text = (text, helptext) - options = _wizard_options("home", "aliases", "destination") + options = _wizard_options("home", "location", "destination", search=True) options.append({"key": "_default", "goto": (_set_property, dict(prop="home", @@ -1557,20 +1568,23 @@ def node_home(caller): def node_destination(caller): text = """ - The object's |cDestination|n is usually only set for Exit-like objects and designates where + The object's |cDestination|n is generally only used by Exit-like objects to designate where the exit 'leads to'. It's usually unset for all other types of objects. {current} """.format(current=_get_current_value(caller, "destination")) helptext = """ - The destination can be given as a #dbref but can also be explicitly searched for using - $obj(name). - """ + The destination can be given as a #dbref but can also be specified using the protfunc + '$obj(name)'. Use |wSEearch to find objects in the database. + + |c$protfuncs|n + {pfuncs} + """.format(pfuncs=_format_protfuncs()) text = (text, helptext) - options = _wizard_options("destination", "home", "prototype_desc") + options = _wizard_options("destination", "home", "prototype_desc", search=True) options.append({"key": "_default", "goto": (_set_property, dict(prop="dest", @@ -1585,8 +1599,7 @@ def node_destination(caller): def node_prototype_desc(caller): text = """ - The |cPrototype-Description|n optionally briefly describes the prototype when it's viewed in - listings. + The |cPrototype-Description|n briefly describes the prototype when it's viewed in listings. {current} """.format(current=_get_current_value(caller, "prototype_desc")) @@ -1602,7 +1615,7 @@ def node_prototype_desc(caller): "goto": (_set_property, dict(prop='prototype_desc', processor=lambda s: s.strip(), - next_node="node_prototype_tags"))}) + next_node="node_prototype_desc"))}) return text, options @@ -1610,14 +1623,87 @@ def node_prototype_desc(caller): # prototype_tags node +def _caller_prototype_tags(caller): + prototype = _get_menu_prototype(caller) + tags = prototype.get("prototype_tags", []) + return tags + + +def _add_prototype_tag(caller, tag_string, **kwargs): + """ + Add prototype_tags to the system. We only support straight tags, no + categories (category is assigned automatically). + + Args: + caller (Object): Caller of menu. + tag_string (str): Input from user - only tagname + + Kwargs: + delete (str): If this is set, tag_string is considered + the name of the tag to delete. + + Returns: + result (str): Result string of action. + + """ + tag = tag_string.strip().lower() + + if tag: + prot = _get_menu_prototype(caller) + tags = prot.get('prototype_tags', []) + exists = tag in tags + + if 'delete' in kwargs: + if exists: + tags.pop(tags.index(tag)) + text = "Removed Prototype-Tag '{}'.".format(tag) + else: + text = "Found no Prototype-Tag to remove." + elif not exists: + # a fresh, new tag + tags.append(tag) + text = "Added Prototype-Tag '{}'.".format(tag) + else: + text = "Prototype-Tag already added." + + _set_prototype_value(caller, "prototype_tags", tags) + else: + text = "No Prototype-Tag specified." + + return text + + +def _prototype_tag_select(caller, tagname): + caller.msg("Prototype-Tag: {}".format(tagname)) + return "node_prototype_tags" + + +def _prototype_tags_actions(caller, raw_inp, **kwargs): + """Parse actions for tags listing""" + choices = kwargs.get("available_choices", []) + tagname, action = _default_parse( + raw_inp, choices, ('remove', 'r', 'delete', 'd')) + + if tagname: + if action == 'remove': + res = _add_prototype_tag(caller, tagname, delete=True) + caller.msg(res) + else: + res = _add_prototype_tag(caller, raw_inp.lower().strip()) + caller.msg(res) + return "node_prototype_tags" + + +@list_node(_caller_prototype_tags, _prototype_tag_select) def node_prototype_tags(caller): text = """ |cPrototype-Tags|n can be used to classify and find prototypes in listings Tag names are not - case-sensitive and can have not have a custom category. Separate multiple tags by commas. + case-sensitive and can have not have a custom category. - {current} - """.format(current=_get_current_value(caller, "prototype_tags")) + {actions} + """.format(actions=_format_list_actions( + "remove", prefix="|w|n|W to add Tag. Other Action:|n ")) helptext = """ Using prototype-tags is a good way to organize and group large numbers of prototypes by genre, type etc. Under the hood, prototypes' tags will all be stored with the category @@ -1628,17 +1714,73 @@ def node_prototype_tags(caller): options = _wizard_options("prototype_tags", "prototype_desc", "prototype_locks") options.append({"key": "_default", - "goto": (_set_property, - dict(prop="prototype_tags", - processor=lambda s: [ - str(part.strip().lower()) for part in s.split(",")], - next_node="node_prototype_locks"))}) + "goto": _prototype_tags_actions}) + return text, options # prototype_locks node +def _caller_prototype_locks(caller): + locks = _get_menu_prototype(caller).get("prototype_locks", "") + return [lck for lck in locks.split(";") if lck] + + +def _prototype_lock_select(caller, lockstr): + return "node_examine_entity", {"text": _locks_display(caller, lockstr), "back": "prototype_locks"} + + +def _prototype_lock_add(caller, lock, **kwargs): + locks = _caller_prototype_locks(caller) + + try: + locktype, lockdef = lock.split(":", 1) + except ValueError: + return "Lockstring lacks ':'." + + locktype = locktype.strip().lower() + + if 'delete' in kwargs: + try: + ind = locks.index(lock) + locks.pop(ind) + _set_prototype_value(caller, "prototype_locks", ";".join(locks), parse=False) + ret = "Prototype-lock {} deleted.".format(lock) + except ValueError: + ret = "No Prototype-lock found to delete." + return ret + try: + locktypes = [lck.split(":", 1)[0].strip().lower() for lck in locks] + ind = locktypes.index(locktype) + locks[ind] = lock + ret = "Prototype-lock with locktype '{}' updated.".format(locktype) + except ValueError: + locks.append(lock) + ret = "Added Prototype-lock '{}'.".format(lock) + _set_prototype_value(caller, "prototype_locks", ";".join(locks)) + return ret + + +def _prototype_locks_actions(caller, raw_inp, **kwargs): + choices = kwargs.get("available_choices", []) + lock, action = _default_parse( + raw_inp, choices, ("examine", "e"), ("remove", "r", "delete", "d")) + + if lock: + if action == 'examine': + return "node_examine_entity", {"text": _locks_display(caller, lock), "back": "locks"} + elif action == 'remove': + ret = _prototype_lock_add(caller, lock.strip(), delete=True) + caller.msg(ret) + else: + ret = _prototype_lock_add(caller, raw_inp.strip()) + caller.msg(ret) + + return "node_prototype_locks" + + +@list_node(_caller_prototype_locks, _prototype_lock_select) def node_prototype_locks(caller): text = """ @@ -1650,25 +1792,23 @@ def node_prototype_locks(caller): - 'edit': Who can edit the prototype. - 'spawn': Who can spawn new objects with this prototype. - If unsure, leave as default. + If unsure, keep the open defaults. - {current} - """.format(current=_get_current_value(caller, "prototype_locks")) + {actions} + """.format(actions=_format_list_actions('examine', "remove", prefix="Actions: ")) helptext = """ - Prototype locks can be used when there are different tiers of builders or for developers to - produce 'base prototypes' only meant for builders to inherit and expand on rather than - change. + Prototype locks can be used to vary access for different tiers of builders. It also allows + developers to produce 'base prototypes' only meant for builders to inherit and expand on + rather than tweak in-place. """ text = (text, helptext) options = _wizard_options("prototype_locks", "prototype_tags", "index") options.append({"key": "_default", - "goto": (_set_property, - dict(prop="prototype_locks", - processor=lambda s: s.strip().lower(), - next_node="node_index"))}) + "goto": _prototype_locks_actions}) + return text, options diff --git a/evennia/prototypes/tests.py b/evennia/prototypes/tests.py index 92b8e85a65..9da6ef44bc 100644 --- a/evennia/prototypes/tests.py +++ b/evennia/prototypes/tests.py @@ -482,6 +482,10 @@ class TestMenuModule(EvenniaTest): self.assertEqual(olc_menus._add_perm(caller, "foo2"), "Added Permission 'foo2'") self.assertEqual(olc_menus._get_menu_prototype(caller)["permissions"], ["foo", "foo2"]) + # prototype_tags helpers + self.assertEqual(olc_menus._add_prototype_tag(caller, "foo"), "Added Tag 'foo'.") + self.assertEqual(olc_menus._add_prototype_tag(caller, "foo2"), "Added Tag 'foo2'.") + self.assertEqual(olc_menus._get_menu_prototype(caller)["prototype_tags"], ["foo", "foo2"]) # spawn helpers with mock.patch("evennia.prototypes.menus.protlib.search_prototype", diff --git a/evennia/utils/evmenu.py b/evennia/utils/evmenu.py index a2c7429d0d..078ddf89c6 100644 --- a/evennia/utils/evmenu.py +++ b/evennia/utils/evmenu.py @@ -1117,11 +1117,18 @@ def list_node(option_generator, select=None, pagesize=10): # add data from the decorated node decorated_options = [] + supports_kwargs = bool(getargspec(func).keywords) try: - text, decorated_options = func(caller, raw_string) + if supports_kwargs: + text, decorated_options = func(caller, raw_string, **kwargs) + else: + text, decorated_options = func(caller, raw_string) except TypeError: try: - text, decorated_options = func(caller) + if supports_kwargs: + text, decorated_options = func(caller, **kwargs) + else: + text, decorated_options = func(caller) except Exception: raise except Exception: From 8b211ee249e0b767ddb6b68249a1dca58b47db43 Mon Sep 17 00:00:00 2001 From: Griatch Date: Sun, 29 Jul 2018 10:51:52 +0200 Subject: [PATCH 183/208] Limit current view for certain fields in olc --- evennia/prototypes/menus.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/evennia/prototypes/menus.py b/evennia/prototypes/menus.py index 9024223c31..5c4bb200a2 100644 --- a/evennia/prototypes/menus.py +++ b/evennia/prototypes/menus.py @@ -269,11 +269,13 @@ def _format_list_actions(*args, **kwargs): return prefix + "|W,|n ".join(actions) -def _get_current_value(caller, keyname, formatter=str): +def _get_current_value(caller, keyname, formatter=str, only_inherit=False): "Return current value, marking if value comes from parent or set in this prototype" prot = _get_menu_prototype(caller) if keyname in prot: # value in current prot + if only_inherit: + return '' return "Current {}: {}".format(keyname, formatter(prot[keyname])) flat_prot = _get_flat_menu_prototype(caller) if keyname in flat_prot: @@ -282,7 +284,11 @@ def _get_current_value(caller, keyname, formatter=str): # we don't inherit prototype_keys return "[No prototype_key set] (|rnot inherited|n)" else: - return "Current {} (|binherited|n): {}".format(keyname, formatter(flat_prot[keyname])) + ret = "Current {} (|binherited|n): {}".format(keyname, formatter(flat_prot[keyname])) + if only_inherit: + return "{}\n\n".format(ret) + return ret + return "[No {} set]".format(keyname) @@ -930,7 +936,7 @@ def node_aliases(caller): |cAliases|n are alternative ways to address an object, next to its |cKey|n. Aliases are not case sensitive. - {actions} + {current}{actions} """.format(actions=_format_list_actions("remove", prefix="|w|W to add new alias. Other action: ")) helptext = """ From 5c84b1c4067190d2763d9d7ea8b84c0e136f7149 Mon Sep 17 00:00:00 2001 From: Griatch Date: Sun, 29 Jul 2018 11:57:16 +0200 Subject: [PATCH 184/208] Refactor locale stepping in olc --- evennia/prototypes/menus.py | 40 +++++++++++++++++++++---------------- 1 file changed, 23 insertions(+), 17 deletions(-) diff --git a/evennia/prototypes/menus.py b/evennia/prototypes/menus.py index 5c4bb200a2..3a3a4c4ff3 100644 --- a/evennia/prototypes/menus.py +++ b/evennia/prototypes/menus.py @@ -131,7 +131,7 @@ def _set_property(caller, raw_string, **kwargs): """ prop = kwargs.get("prop", "prototype_key") processor = kwargs.get("processor", None) - next_node = kwargs.get("next_node", "node_index") + next_node = kwargs.get("next_node", None) if callable(processor): try: @@ -346,6 +346,8 @@ def node_validate_prototype(caller, raw_string, **kwargs): return text, options +# node examine_entity + def node_examine_entity(caller, raw_string, **kwargs): """ General node to view a text and then return to previous node. Kwargs should contain "text" for @@ -364,6 +366,8 @@ def node_examine_entity(caller, raw_string, **kwargs): return text, options +# node object_search + def _search_object(caller): "update search term based on query stored on menu; store match too" try: @@ -399,7 +403,7 @@ def _search_object(caller): return ["{}(#{})".format(obj.key, obj.id) for obj in results] -def _object_select(caller, obj_entry, **kwargs): +def _object_search_select(caller, obj_entry, **kwargs): choices = kwargs['available_choices'] num = choices.index(obj_entry) matches = caller.ndb._menutree.olc_search_object_matches @@ -415,12 +419,14 @@ def _object_select(caller, obj_entry, **kwargs): return "node_examine_entity", {"text": txt, "back": "search_object"} -def _object_actions(caller, raw_inp, **kwargs): +def _object_search_actions(caller, raw_inp, **kwargs): "All this does is to queue a search query" choices = kwargs['available_choices'] obj_entry, action = _default_parse( raw_inp, choices, ("examine", "e"), ("create prototype from object", "create", "c")) + raw_inp = raw_inp.strip() + if obj_entry: num = choices.index(obj_entry) @@ -448,12 +454,16 @@ def _object_actions(caller, raw_inp, **kwargs): _set_menu_prototype(caller, prot) caller.msg("Created prototype from object.") return "node_index" - else: + elif raw_inp: caller.ndb._menutree.olc_search_object_term = raw_inp return "node_search_object", kwargs + else: + # empty input - exit back to previous node + prev_node = "node_" + kwargs.get("back", "index") + return prev_node -@list_node(_search_object, _object_select) +@list_node(_search_object, _object_search_select) def node_search_object(caller, raw_inp, **kwargs): """ Node for searching for an existing object. @@ -491,7 +501,7 @@ def node_search_object(caller, raw_inp, **kwargs): options = _wizard_options(None, prev_node, None) options.append({"key": "_default", - "goto": (_object_actions, {"back": prev_node})}) + "goto": (_object_search_actions, {"back": prev_node})}) return text, options @@ -601,7 +611,7 @@ def _check_prototype_key(caller, key): caller.msg("Prototype already exists. Reloading.") return "node_index" - return _set_property(caller, key, prop='prototype_key', next_node="node_prototype_parent") + return _set_property(caller, key, prop='prototype_key') def node_prototype_key(caller): @@ -814,7 +824,7 @@ def _typeclass_actions(caller, raw_inp, **kwargs): def _typeclass_select(caller, typeclass): """Select typeclass from list and add it to prototype. Return next node to go to.""" - ret = _set_property(caller, typeclass, prop='typeclass', processor=str, next_node="node_key") + ret = _set_property(caller, typeclass, prop='typeclass', processor=str) caller.msg("Selected typeclass |c{}|n.".format(typeclass)) return ret @@ -874,8 +884,7 @@ def node_key(caller): options.append({"key": "_default", "goto": (_set_property, dict(prop="key", - processor=lambda s: s.strip(), - next_node="node_aliases"))}) + processor=lambda s: s.strip()))}) return text, options @@ -936,7 +945,7 @@ def node_aliases(caller): |cAliases|n are alternative ways to address an object, next to its |cKey|n. Aliases are not case sensitive. - {current}{actions} + {actions} """.format(actions=_format_list_actions("remove", prefix="|w|W to add new alias. Other action: ")) helptext = """ @@ -1526,8 +1535,7 @@ def node_location(caller): options.append({"key": "_default", "goto": (_set_property, dict(prop="location", - processor=lambda s: s.strip(), - next_node="node_home"))}) + processor=lambda s: s.strip()))}) return text, options @@ -1563,8 +1571,7 @@ def node_home(caller): options.append({"key": "_default", "goto": (_set_property, dict(prop="home", - processor=lambda s: s.strip(), - next_node="node_destination"))}) + processor=lambda s: s.strip()))}) return text, options @@ -1594,8 +1601,7 @@ def node_destination(caller): options.append({"key": "_default", "goto": (_set_property, dict(prop="dest", - processor=lambda s: s.strip(), - next_node="node_prototype_desc"))}) + processor=lambda s: s.strip()))}) return text, options From 39c6eaf8dec73bfda1893c91acf727169c1beb6c Mon Sep 17 00:00:00 2001 From: Griatch Date: Sun, 29 Jul 2018 12:06:11 +0200 Subject: [PATCH 185/208] Fix destination setting in olc --- evennia/prototypes/menus.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/evennia/prototypes/menus.py b/evennia/prototypes/menus.py index 3a3a4c4ff3..fb6543c70b 100644 --- a/evennia/prototypes/menus.py +++ b/evennia/prototypes/menus.py @@ -1600,7 +1600,7 @@ def node_destination(caller): options = _wizard_options("destination", "home", "prototype_desc", search=True) options.append({"key": "_default", "goto": (_set_property, - dict(prop="dest", + dict(prop="destination", processor=lambda s: s.strip()))}) return text, options From 7f8bd983f03638603905998b3a57c409acabfde8 Mon Sep 17 00:00:00 2001 From: Griatch Date: Sun, 29 Jul 2018 16:53:54 +0200 Subject: [PATCH 186/208] Refactor spawn, update remaining in olc --- evennia/prototypes/menus.py | 183 +++++++++++++++++++++++----------- evennia/prototypes/spawner.py | 12 ++- evennia/utils/evmenu.py | 5 +- 3 files changed, 135 insertions(+), 65 deletions(-) diff --git a/evennia/prototypes/menus.py b/evennia/prototypes/menus.py index fb6543c70b..8574e944cf 100644 --- a/evennia/prototypes/menus.py +++ b/evennia/prototypes/menus.py @@ -1874,10 +1874,25 @@ def node_update_objects(caller, **kwargs): diff, obj_prototype = spawner.prototype_diff_from_object(prototype, obj) text = ["Suggested changes to {} objects. ".format(len(update_objects)), - "Showing random example obj to change: {name} (#{dbref}))\n".format(obj.key, obj.dbref)] - options = [] + "Showing random example obj to change: {name} ({dbref}))\n".format( + name=obj.key, dbref=obj.dbref)] + + helptext = """ + Be careful with this operation! The upgrade mechanism will try to automatically estimate + what changes need to be applied. But the estimate is |wonly based on the analysis of one + randomly selected object|n among all objects spawned by this prototype. If that object + happens to be unusual in some way the estimate will be off and may lead to unexpected + results for other objects. Always test your objects carefully after an upgrade and + consider being conservative (switch to KEEP) or even do the update manually if you are + unsure that the results will be acceptable. """ + + options = _wizard_options("update_objects", back_node[5:], None) io = 0 for (key, inst) in sorted(((key, val) for key, val in diff.items()), key=lambda tup: tup[0]): + + if key in protlib._PROTOTYPE_META_NAMES: + continue + line = "{iopt} |w{key}|n: {old}{sep}{new} {change}" old_val = utils.crop(str(obj_prototype[key]), width=20) @@ -1907,18 +1922,11 @@ def node_update_objects(caller, **kwargs): {"key": "|wb|rack ({})".format(back_node[5:], 'b'), "goto": back_node}]) - helptext = """ - Be careful with this operation! The upgrade mechanism will try to automatically estimate - what changes need to be applied. But the estimate is |wonly based on the analysis of one - randomly selected object|n among all objects spawned by this prototype. If that object - happens to be unusual in some way the estimate will be off and may lead to unexpected - results for other objects. Always test your objects carefully after an upgrade and - consider being conservative (switch to KEEP) or even do the update manually if you are - unsure that the results will be acceptable. """ + text = "\n".join(text) - text = (text, helptext) + text = (text, helptext) - return text, options + return text, options # prototype save node @@ -1928,7 +1936,8 @@ def node_prototype_save(caller, **kwargs): """Save prototype to disk """ # these are only set if we selected 'yes' to save on a previous pass prototype = kwargs.get("prototype", None) - accept_save = kwargs.get("accept_save", False) + # set to True/False if answered, None if first pass + accept_save = kwargs.get("accept_save", None) if accept_save and prototype: # we already validated and accepted the save, so this node acts as a goto callback and @@ -1939,22 +1948,38 @@ def node_prototype_save(caller, **kwargs): spawned_objects = protlib.search_objects_with_prototype(prototype_key) nspawned = spawned_objects.count() + text = ["|gPrototype saved.|n"] + if nspawned: - text = ("Do you want to update {} object(s) " - "already using this prototype?".format(nspawned)) + text.append("\nDo you want to update {} object(s) " + "already using this prototype?".format(nspawned)) options = ( {"key": ("|wY|Wes|n", "yes", "y"), + "desc": "Go to updating screen", "goto": ("node_update_objects", {"accept_update": True, "objects": spawned_objects, "prototype": prototype, "back_node": "node_prototype_save"})}, {"key": ("[|wN|Wo|n]", "n"), - "goto": "node_spawn"}, + "desc": "Return to index", + "goto": "node_index"}, {"key": "_default", - "goto": "node_spawn"}) + "goto": "node_index"}) else: - text = "|gPrototype saved.|n" + text.append("(press Return to continue)") options = {"key": "_default", - "goto": "node_spawn"} + "goto": "node_index"} + + text = "\n".join(text) + + helptext = """ + Updating objects means that the spawner will find all objects previously created by this + prototype. You will be presented with a list of the changes the system will try to apply to + each of these objects and you can choose to customize that change if needed. If you have + done a lot of manual changes to your objects after spawning, you might want to update those + objects manually instead. + """ + + text = (text, helptext) return text, options @@ -1967,27 +1992,19 @@ def node_prototype_save(caller, **kwargs): if error: # abort save text.append( - "Validation errors were found. They need to be corrected before this prototype " - "can be saved (or used to spawn).") - options = _wizard_options("prototype_save", "prototype_locks", "index") + "\n|yValidation errors were found. They need to be corrected before this prototype " + "can be saved (or used to spawn).|n") + options = _wizard_options("prototype_save", "index", None) return "\n".join(text), options prototype_key = prototype['prototype_key'] if protlib.search_prototype(prototype_key): - text.append("Do you want to save/overwrite the existing prototype '{name}'?".format( + text.append("\nDo you want to save/overwrite the existing prototype '{name}'?".format( name=prototype_key)) else: - text.append("Do you want to save the prototype as '{name}'?".format(prototype_key)) + text.append("\nDo you want to save the prototype as '{name}'?".format(name=prototype_key)) - options = ( - {"key": ("[|wY|Wes|n]", "yes", "y"), - "goto": ("node_prototype_save", - {"accept": True, "prototype": prototype})}, - {"key": ("|wN|Wo|n", "n"), - "goto": "node_spawn"}, - {"key": "_default", - "goto": ("node_prototype_save", - {"accept": True, "prototype": prototype})}) + text = "\n".join(text) helptext = """ Saving the prototype makes it available for use later. It can also be used to inherit from, @@ -1999,6 +2016,18 @@ def node_prototype_save(caller, **kwargs): text = (text, helptext) + options = ( + {"key": ("[|wY|Wes|n]", "yes", "y"), + "desc": "Save prototype", + "goto": ("node_prototype_save", + {"accept_save": True, "prototype": prototype})}, + {"key": ("|wN|Wo|n", "n"), + "desc": "Abort and return to Index", + "goto": "node_index"}, + {"key": "_default", + "goto": ("node_prototype_save", + {"accept_save": True, "prototype": prototype})}) + return text, options @@ -2015,26 +2044,42 @@ def _spawn(caller, **kwargs): obj = spawner.spawn(prototype) if obj: obj = obj[0] - caller.msg("|gNew instance|n {key} ({dbref}) |gspawned.|n".format( - key=obj.key, dbref=obj.dbref)) + text = "|gNew instance|n {key} ({dbref}) |gspawned.|n".format( + key=obj.key, dbref=obj.dbref) else: - caller.msg("|rError: Spawner did not return a new instance.|n") - return obj + text = "|rError: Spawner did not return a new instance.|n" + return "node_examine_entity", {"text": text, "back": "prototype_spawn"} def node_prototype_spawn(caller, **kwargs): """Submenu for spawning the prototype""" prototype = _get_menu_prototype(caller) - error, text = _validate_prototype(prototype) - text = [text] + already_validated = kwargs.get("already_validated", False) + + if already_validated: + error, text = None, [] + else: + error, text = _validate_prototype(prototype) + text = [text] if error: - text.append("|rPrototype validation failed. Correct the errors before spawning.|n") - options = _wizard_options("prototype_spawn", "prototype_locks", "index") + text.append("\n|rPrototype validation failed. Correct the errors before spawning.|n") + options = _wizard_options("prototype_spawn", "index", None) return "\n".join(text), options + text = "\n".join(text) + + helptext = """ + Spawning is the act of instantiating a prototype into an actual object. As a new object is + spawned, every $protfunc in the prototype is called anew. Since this is a common thing to + do, you may also temporarily change the |clocation|n of this prototype to bypass whatever + value is set in the prototype. + + """ + text = (text, helptext) + # show spawn submenu options options = [] prototype_key = prototype['prototype_key'] @@ -2064,18 +2109,10 @@ def node_prototype_spawn(caller, **kwargs): options.append( {"desc": "Update {num} existing objects with this prototype".format(num=nspawned), "goto": ("node_update_objects", - dict(prototype=prototype, opjects=spawned_objects, - back_node="node_prototype_spawn"))}) - options.extend(_wizard_options("prototype_spawn", "prototype_save", "index")) - - helptext = """ - Spawning is the act of instantiating a prototype into an actual object. As a new object is - spawned, every $protfunc in the prototype is called anew. Since this is a common thing to - do, you may also temporarily change the |clocation|n of this prototype to bypass whatever - value is set in the prototype. - - """ - text = (text, helptext) + {"objects": list(spawned_objects), + "prototype": prototype, + "back_node": "node_prototype_spawn"})}) + options.extend(_wizard_options("prototype_spawn", "index", None)) return text, options @@ -2088,30 +2125,56 @@ def _prototype_load_select(caller, prototype_key): if matches: prototype = matches[0] _set_menu_prototype(caller, prototype) - caller.msg("|gLoaded prototype '{}'.".format(prototype_key)) - return "node_index" + return "node_examine_entity", \ + {"text": "|gLoaded prototype {}.|n".format(prototype['prototype_key']), + "back": "index"} else: caller.msg("|rFailed to load prototype '{}'.".format(prototype_key)) return None +def _prototype_load_actions(caller, raw_inp, **kwargs): + """Parse the default Convert prototype to a string representation for closer inspection""" + choices = kwargs.get("available_choices", []) + prototype, action = _default_parse( + raw_inp, choices, ("examine", "e", "l")) + + if prototype: + # a selection of parent was made + prototype = protlib.search_prototype(key=prototype)[0] + + # which action to apply on the selection + if action == 'examine': + # examine the prototype + txt = protlib.prototype_to_str(prototype) + kwargs['text'] = txt + kwargs['back'] = 'prototype_load' + return "node_examine_entity", kwargs + + return 'node_prototype_load' + + @list_node(_all_prototype_parents, _prototype_load_select) def node_prototype_load(caller, **kwargs): """Load prototype""" text = """ Select a prototype to load. This will replace any prototype currently being edited! - """ + + {actions} + """.format(actions=_format_list_actions("examine")) + helptext = """ - Loading a prototype will load it and return you to the main index. It can be a good idea to - examine the prototype before loading it. + Loading a prototype will load it and return you to the main index. It can be a good idea + to examine the prototype before loading it. """ text = (text, helptext) - options = _wizard_options("prototype_load", "prototype_save", "index") + options = _wizard_options("prototype_load", "index", None) options.append({"key": "_default", - "goto": _prototype_parent_actions}) + "goto": _prototype_load_actions}) + return text, options diff --git a/evennia/prototypes/spawner.py b/evennia/prototypes/spawner.py index 1bae219368..f250287c8f 100644 --- a/evennia/prototypes/spawner.py +++ b/evennia/prototypes/spawner.py @@ -553,7 +553,9 @@ def spawn(*prototypes, **kwargs): alias_string = init_spawn_value(val, make_iter) val = prot.pop("tags", []) - tags = init_spawn_value(val, make_iter) + tags = [] + for (tag, category, data) in tags: + tags.append((init_spawn_value(val, str), category, data)) prototype_key = prototype.get('prototype_key', None) if prototype_key: @@ -567,9 +569,11 @@ def spawn(*prototypes, **kwargs): nattributes = dict((key.split("_", 1)[1], init_spawn_value(val, value_to_obj)) for key, val in prot.items() if key.startswith("ndb_")) - # the rest are attributes - val = prot.pop("attrs", []) - attributes = init_spawn_value(val, list) + # the rest are attribute tuples (attrname, value, category, locks) + val = make_iter(prot.pop("attrs", [])) + attributes = [] + for (attrname, value, category, locks) in val: + attributes.append((attrname, init_spawn_value(val), category, locks)) simple_attributes = [] for key, value in ((key, value) for key, value in prot.items() diff --git a/evennia/utils/evmenu.py b/evennia/utils/evmenu.py index 078ddf89c6..9941c81b11 100644 --- a/evennia/utils/evmenu.py +++ b/evennia/utils/evmenu.py @@ -938,7 +938,7 @@ class EvMenu(object): for key, desc in optionlist: if not (key or desc): continue - desc_string = ": %s" % (desc if desc else "") + desc_string = ": %s" % desc if desc else "" table_width_max = max(table_width_max, max(m_len(p) for p in key.split("\n")) + max(m_len(p) for p in desc_string.split("\n")) + colsep) @@ -1140,9 +1140,12 @@ def list_node(option_generator, select=None, pagesize=10): decorated_options = make_iter(decorated_options) extra_options = [] + if isinstance(decorated_options, dict): + decorated_options = [decorated_options] for eopt in decorated_options: cback = ("goto" in eopt and "goto") or ("exec" in eopt and "exec") or None if cback: + print("eopt, cback: {} {}".format(eopt, cback)) signature = eopt[cback] if callable(signature): # callable with no kwargs defined From 5cca160989bcc91ebd8861fec475b0ad32cdfab6 Mon Sep 17 00:00:00 2001 From: Griatch Date: Sun, 29 Jul 2018 20:58:56 +0200 Subject: [PATCH 187/208] Further cleanup and debugging of olc menu --- evennia/prototypes/menus.py | 99 ++++++++++++++++++++------------ evennia/prototypes/prototypes.py | 12 ++-- evennia/utils/evmenu.py | 1 - 3 files changed, 69 insertions(+), 43 deletions(-) diff --git a/evennia/prototypes/menus.py b/evennia/prototypes/menus.py index 8574e944cf..22a07903c3 100644 --- a/evennia/prototypes/menus.py +++ b/evennia/prototypes/menus.py @@ -518,14 +518,11 @@ def node_index(caller): can be hard-coded or scripted using |w$protfuncs|n - for example to randomize the value every time the prototype is used to spawn a new entity. - The prototype fields named 'prototype_*' are not used to create the entity itself but for - organizing the template when saving it for you (and maybe others) to use later. + The prototype fields whose names start with 'Prototype-' are not fields on the object itself + but are used in the template and when saving it for you (and maybe others) to use later. + Select prototype field to edit. If you are unsure, start from [|w1|n]. Enter [|wh|n]elp at + any menu node for more info. - Select prototype field to edit. If you are unsure, start from [|w1|n]. At any time you can - [|wV|n]alidate that the prototype works correctly and use it to [|wSP|n]awn a new entity. You - can also [|wSA|n]ve|n your work or [|wLO|n]oad an existing prototype to use as a base. Use - [|wL|n]ook to re-show a menu node. [|wQ|n]uit will always exit the menu and [|wH|n]elp will - show context-sensitive help. """ helptxt = """ |c- prototypes |n @@ -537,6 +534,13 @@ def node_index(caller): to spawn goblins with different names, looks, equipment and skill, each based on the same `Goblin` typeclass. + At any time you can [|wV|n]alidate that the prototype works correctly and use it to + [|wSP|n]awn a new entity. You can also [|wSA|n]ve|n your work, [|wLO|n]oad an existing + prototype to [|wSE|n]arch for existing objects to use as a base. Use [|wL|n]ook to re-show a + menu node. [|wQ|n]uit will always exit the menu and [|wH|n]elp will show context-sensitive + help. + + |c- $protfuncs |n Prototype-functions (protfuncs) allow for limited scripting within a prototype. These are @@ -553,11 +557,11 @@ def node_index(caller): {"desc": "|WPrototype-Key|n|n{}".format( _format_option_value("Key", "prototype_key" not in prototype, prototype, None)), "goto": "node_prototype_key"}) - for key in ('Prototype_parent', 'Typeclass', 'Key', 'Aliases', 'Attrs', 'Tags', 'Locks', + for key in ('Prototype_Parent', 'Typeclass', 'Key', 'Aliases', 'Attrs', 'Tags', 'Locks', 'Permissions', 'Location', 'Home', 'Destination'): required = False cropper = None - if key in ("Prototype_parent", "Typeclass"): + if key in ("Prototype_Parent", "Typeclass"): required = ("prototype_parent" not in prototype) and ("typeclass" not in prototype) if key == 'Typeclass': cropper = _path_cropper @@ -1827,7 +1831,7 @@ def node_prototype_locks(caller): # update existing objects node -def _update_spawned(caller, **kwargs): +def _apply_diff(caller, **kwargs): """update existing objects""" prototype = kwargs['prototype'] objects = kwargs['objects'] @@ -1844,7 +1848,7 @@ def _keep_diff(caller, **kwargs): diff[key] = "KEEP" -def node_update_objects(caller, **kwargs): +def node_apply_diff(caller, **kwargs): """Offer options for updating objects""" def _keep_option(keyname, prototype, obj, obj_prototype, diff, objects, back_node): @@ -1886,8 +1890,9 @@ def node_update_objects(caller, **kwargs): consider being conservative (switch to KEEP) or even do the update manually if you are unsure that the results will be acceptable. """ - options = _wizard_options("update_objects", back_node[5:], None) - io = 0 + options = [] + + ichanges = 0 for (key, inst) in sorted(((key, val) for key, val in diff.items()), key=lambda tup: tup[0]): if key in protlib._PROTOTYPE_META_NAMES: @@ -1897,30 +1902,40 @@ def node_update_objects(caller, **kwargs): old_val = utils.crop(str(obj_prototype[key]), width=20) if inst == "KEEP": - text.append(line.format(iopt='', key=key, old=old_val, sep=" ", new='', change=inst)) + inst = "|b{}|n".format(inst) + text.append(line.format(iopt='', key=key, old=old_val, + sep=" ", new='', change=inst)) continue new_val = utils.crop(str(spawner.init_spawn_value(prototype[key])), width=20) - io += 1 + ichanges += 1 if inst in ("UPDATE", "REPLACE"): - text.append(line.format(iopt=io, key=key, old=old_val, + inst = "|y{}|n".format(inst) + text.append(line.format(iopt=ichanges, key=key, old=old_val, sep=" |y->|n ", new=new_val, change=inst)) options.append(_keep_option(key, prototype, obj, obj_prototype, diff, update_objects, back_node)) elif inst == "REMOVE": - text.append(line.format(iopt=io, key=key, old=old_val, + inst = "|r{}|n".format(inst) + text.append(line.format(iopt=ichanges, key=key, old=old_val, sep=" |r->|n ", new='', change=inst)) options.append(_keep_option(key, prototype, obj, obj_prototype, diff, update_objects, back_node)) options.extend( - [{"key": ("|wu|r update {} objects".format(len(update_objects)), "update", "u"), - "goto": (_update_spawned, {"prototype": prototype, "objects": update_objects, - "back_node": back_node, "diff": diff})}, - {"key": ("|wr|neset changes", "reset", "r"), - "goto": ("node_update_objects", {"prototype": prototype, "back_node": back_node, - "objects": update_objects})}, - {"key": "|wb|rack ({})".format(back_node[5:], 'b'), - "goto": back_node}]) + [{"key": ("|wu|Wupdate {} objects".format(len(update_objects)), "update", "u"), + "goto": (_apply_diff, {"prototye": prototype, "objects": update_objects, + "back_node": back_node, "diff": diff})}, + {"key": ("|wr|Wneset changes", "reset", "r"), + "goto": ("node_apply_diff", {"prototype": prototype, "back_node": back_node, + "objects": update_objects})}]) + + if ichanges < 1: + text = ["Analyzed a random sample object (out of {}) - " + "found no changes to apply.".format(len(update_objects))] + + options.extend(_wizard_options("update_objects", back_node[5:], None)) + options.append({"key": "_default", + "goto": back_node}) text = "\n".join(text) @@ -1956,7 +1971,7 @@ def node_prototype_save(caller, **kwargs): options = ( {"key": ("|wY|Wes|n", "yes", "y"), "desc": "Go to updating screen", - "goto": ("node_update_objects", + "goto": ("node_apply_diff", {"accept_update": True, "objects": spawned_objects, "prototype": prototype, "back_node": "node_prototype_save"})}, {"key": ("[|wN|Wo|n]", "n"), @@ -1995,6 +2010,8 @@ def node_prototype_save(caller, **kwargs): "\n|yValidation errors were found. They need to be corrected before this prototype " "can be saved (or used to spawn).|n") options = _wizard_options("prototype_save", "index", None) + options.append({"key": "_default", + "goto": "node_index"}) return "\n".join(text), options prototype_key = prototype['prototype_key'] @@ -2044,8 +2061,8 @@ def _spawn(caller, **kwargs): obj = spawner.spawn(prototype) if obj: obj = obj[0] - text = "|gNew instance|n {key} ({dbref}) |gspawned.|n".format( - key=obj.key, dbref=obj.dbref) + text = "|gNew instance|n {key} ({dbref}) |gspawned at location |n{loc}|n|g.|n".format( + key=obj.key, dbref=obj.dbref, loc=prototype['location']) else: text = "|rError: Spawner did not return a new instance.|n" return "node_examine_entity", {"text": text, "back": "prototype_spawn"} @@ -2108,11 +2125,13 @@ def node_prototype_spawn(caller, **kwargs): if spawned_objects: options.append( {"desc": "Update {num} existing objects with this prototype".format(num=nspawned), - "goto": ("node_update_objects", + "goto": ("node_apply_diff", {"objects": list(spawned_objects), "prototype": prototype, "back_node": "node_prototype_spawn"})}) options.extend(_wizard_options("prototype_spawn", "index", None)) + options.append({"key": "_default", + "goto": "node_index"}) return text, options @@ -2137,19 +2156,25 @@ def _prototype_load_actions(caller, raw_inp, **kwargs): """Parse the default Convert prototype to a string representation for closer inspection""" choices = kwargs.get("available_choices", []) prototype, action = _default_parse( - raw_inp, choices, ("examine", "e", "l")) + raw_inp, choices, ("examine", "e", "l"), ("delete", "del", "d")) if prototype: - # a selection of parent was made - prototype = protlib.search_prototype(key=prototype)[0] # which action to apply on the selection if action == 'examine': # examine the prototype + prototype = protlib.search_prototype(key=prototype)[0] txt = protlib.prototype_to_str(prototype) - kwargs['text'] = txt - kwargs['back'] = 'prototype_load' - return "node_examine_entity", kwargs + return "node_examine_entity", {"text": txt, "back": 'prototype_load'} + elif action == 'delete': + # delete prototype from disk + try: + protlib.delete_prototype(prototype, caller=caller) + except protlib.PermissionError as err: + txt = "|rDeletion error:|n {}".format(err) + else: + txt = "|gPrototype {} was deleted.|n".format(prototype) + return "node_examine_entity", {"text": txt, "back": "prototype_load"} return 'node_prototype_load' @@ -2162,7 +2187,7 @@ def node_prototype_load(caller, **kwargs): Select a prototype to load. This will replace any prototype currently being edited! {actions} - """.format(actions=_format_list_actions("examine")) + """.format(actions=_format_list_actions("examine", "delete")) helptext = """ Loading a prototype will load it and return you to the main index. It can be a good idea @@ -2246,7 +2271,7 @@ def start_olc(caller, session=None, prototype=None): "node_location": node_location, "node_home": node_home, "node_destination": node_destination, - "node_update_objects": node_update_objects, + "node_apply_diff": node_apply_diff, "node_prototype_desc": node_prototype_desc, "node_prototype_tags": node_prototype_tags, "node_prototype_locks": node_prototype_locks, diff --git a/evennia/prototypes/prototypes.py b/evennia/prototypes/prototypes.py index 8ce20d5311..4c53ed7d1c 100644 --- a/evennia/prototypes/prototypes.py +++ b/evennia/prototypes/prototypes.py @@ -297,10 +297,10 @@ def init_spawn_value(value, validator=None): for mod in settings.PROTOTYPE_MODULES: # to remove a default prototype, override it with an empty dict. # internally we store as (key, desc, locks, tags, prototype_dict) - prots = [(prototype_key, prot) for prototype_key, prot in all_from_module(mod).items() + prots = [(prototype_key.lower(), prot) for prototype_key, prot in all_from_module(mod).items() if prot and isinstance(prot, dict)] # assign module path to each prototype_key for easy reference - _MODULE_PROTOTYPE_MODULES.update({prototype_key: mod for prototype_key, _ in prots}) + _MODULE_PROTOTYPE_MODULES.update({prototype_key.lower(): mod for prototype_key, _ in prots}) # make sure the prototype contains all meta info for prototype_key, prot in prots: actual_prot_key = prot.get('prototype_key', prototype_key).lower() @@ -409,7 +409,7 @@ def save_prototype(**kwargs): create_prototype = save_prototype -def delete_prototype(key, caller=None): +def delete_prototype(prototype_key, caller=None): """ Delete a stored prototype @@ -424,14 +424,16 @@ def delete_prototype(key, caller=None): """ if prototype_key in _MODULE_PROTOTYPES: - mod = _MODULE_PROTOTYPE_MODULES.get(prototype_key, "N/A") + mod = _MODULE_PROTOTYPE_MODULES.get(prototype_key.lower(), "N/A") raise PermissionError("{} is a read-only prototype " "(defined as code in {}).".format(prototype_key, mod)) - stored_prototype = DbPrototype.objects.filter(db_key=prototype_key) + stored_prototype = DbPrototype.objects.filter(db_key__iexact=prototype_key) if not stored_prototype: raise PermissionError("Prototype {} was not found.".format(prototype_key)) + + stored_prototype = stored_prototype[0] if caller: if not stored_prototype.access(caller, 'edit'): raise PermissionError("{} does not have permission to " diff --git a/evennia/utils/evmenu.py b/evennia/utils/evmenu.py index 9941c81b11..638f4eef6e 100644 --- a/evennia/utils/evmenu.py +++ b/evennia/utils/evmenu.py @@ -1145,7 +1145,6 @@ def list_node(option_generator, select=None, pagesize=10): for eopt in decorated_options: cback = ("goto" in eopt and "goto") or ("exec" in eopt and "exec") or None if cback: - print("eopt, cback: {} {}".format(eopt, cback)) signature = eopt[cback] if callable(signature): # callable with no kwargs defined From 44a2540341fbd83fe6ee2bece2e2a18529e8f7ab Mon Sep 17 00:00:00 2001 From: Griatch Date: Mon, 30 Jul 2018 17:38:59 +0200 Subject: [PATCH 188/208] Fix attr assignmen issue in olc menu --- evennia/objects/objects.py | 1 + evennia/prototypes/menus.py | 5 ++++- evennia/prototypes/tests.py | 25 +++++++++++++------------ 3 files changed, 18 insertions(+), 13 deletions(-) diff --git a/evennia/objects/objects.py b/evennia/objects/objects.py index 8ec1433dcd..d42f20c9ae 100644 --- a/evennia/objects/objects.py +++ b/evennia/objects/objects.py @@ -1753,6 +1753,7 @@ class DefaultObject(with_metaclass(TypeclassBase, ObjectDB)): else: msg_self = '{self} say, "{speech}"' if msg_self is True else msg_self msg_location = msg_location or '{object} says, "{speech}"' + msg_receivers = msg_receivers or message custom_mapping = kwargs.get('mapping', {}) receivers = make_iter(receivers) if receivers else None diff --git a/evennia/prototypes/menus.py b/evennia/prototypes/menus.py index 22a07903c3..1141f27536 100644 --- a/evennia/prototypes/menus.py +++ b/evennia/prototypes/menus.py @@ -1023,6 +1023,7 @@ def _add_attr(caller, attr_string, **kwargs): result (str): Result string of action. """ attrname = '' + value = '' category = None locks = '' @@ -1097,7 +1098,7 @@ def _attrs_actions(caller, raw_inp, **kwargs): attrname = attrname.strip() attr_tup = _get_tup_by_attrname(caller, attrname) - if attr_tup: + if action and attr_tup: if action == 'examine': return "node_examine_entity", \ {"text": _display_attribute(attr_tup), "back": "attrs"} @@ -2057,6 +2058,8 @@ def _spawn(caller, **kwargs): new_location = kwargs.get('location', None) if new_location: prototype['location'] = new_location + if not prototype.get('location'): + prototype['location'] = caller obj = spawner.spawn(prototype) if obj: diff --git a/evennia/prototypes/tests.py b/evennia/prototypes/tests.py index 9da6ef44bc..9fb47585c9 100644 --- a/evennia/prototypes/tests.py +++ b/evennia/prototypes/tests.py @@ -399,11 +399,9 @@ class TestMenuModule(EvenniaTest): with mock.patch("evennia.prototypes.menus.protlib.search_prototype", new=mock.MagicMock(return_value=[self.test_prot])): # prototype_key helpers - self.assertEqual(olc_menus._check_prototype_key(caller, "test_prot"), - "node_prototype_parent") + self.assertEqual(olc_menus._check_prototype_key(caller, "test_prot"), None) caller.ndb._menutree.olc_new = True - self.assertEqual(olc_menus._check_prototype_key(caller, "test_prot"), - "node_index") + self.assertEqual(olc_menus._check_prototype_key(caller, "test_prot"), "node_index") # prototype_parent helpers self.assertEqual(olc_menus._all_prototype_parents(caller), ['test_prot']) @@ -429,7 +427,7 @@ class TestMenuModule(EvenniaTest): self.assertEqual(olc_menus._all_typeclasses(caller), ["bar", "foo"]) self.assertEqual(olc_menus._typeclass_select( - caller, "evennia.objects.objects.DefaultObject"), "node_key") + caller, "evennia.objects.objects.DefaultObject"), None) # prototype_parent should be popped off here self.assertEqual(olc_menus._get_menu_prototype(caller), {'prototype_key': 'test_prot', @@ -444,8 +442,9 @@ class TestMenuModule(EvenniaTest): self.assertEqual(olc_menus._add_attr(caller, "test3;cat2;edit:false()=foo3"), Something) self.assertEqual(olc_menus._add_attr(caller, "test4;cat3;set:true();edit:false()=foo4"), Something) self.assertEqual(olc_menus._add_attr(caller, "test5;cat4;set:true();edit:false()=123"), Something) + self.assertEqual(olc_menus._add_attr(caller, "test1=foo1_changed"), Something) self.assertEqual(olc_menus._get_menu_prototype(caller)['attrs'], - [("test1", "foo1", None, ''), + [("test1", "foo1_changed", None, ''), ("test2", "foo2", "cat1", ''), ("test3", "foo3", "cat2", "edit:false()"), ("test4", "foo4", "cat3", "set:true();edit:false()"), @@ -483,27 +482,29 @@ class TestMenuModule(EvenniaTest): self.assertEqual(olc_menus._get_menu_prototype(caller)["permissions"], ["foo", "foo2"]) # prototype_tags helpers - self.assertEqual(olc_menus._add_prototype_tag(caller, "foo"), "Added Tag 'foo'.") - self.assertEqual(olc_menus._add_prototype_tag(caller, "foo2"), "Added Tag 'foo2'.") + self.assertEqual(olc_menus._add_prototype_tag(caller, "foo"), "Added Prototype-Tag 'foo'.") + self.assertEqual(olc_menus._add_prototype_tag(caller, "foo2"), "Added Prototype-Tag 'foo2'.") self.assertEqual(olc_menus._get_menu_prototype(caller)["prototype_tags"], ["foo", "foo2"]) # spawn helpers with mock.patch("evennia.prototypes.menus.protlib.search_prototype", new=mock.MagicMock(return_value=[_PROTPARENTS['GOBLIN']])): - obj = olc_menus._spawn(caller, prototype=self.test_prot) + self.assertEqual(olc_menus._spawn(caller, prototype=self.test_prot), Something) + obj = caller.contents[0] self.assertEqual(obj.typeclass_path, "evennia.objects.objects.DefaultObject") self.assertEqual(obj.tags.get(category=spawner._PROTOTYPE_TAG_CATEGORY), self.test_prot['prototype_key']) # update helpers - self.assertEqual(olc_menus._update_spawned( + self.assertEqual(olc_menus._apply_diff( caller, prototype=self.test_prot, back_node="foo", objects=[obj]), 'foo') # no changes to apply self.test_prot['key'] = "updated key" # change prototype - self.assertEqual(olc_menus._update_spawned( + self.assertEqual(olc_menus._apply_diff( caller, prototype=self.test_prot, objects=[obj], back_node='foo'), 'foo') # apply change to the one obj # load helpers - self.assertEqual(olc_menus._prototype_load_select(caller, self.test_prot['prototype_key']), "node_index") + self.assertEqual(olc_menus._prototype_load_select(caller, self.test_prot['prototype_key']), + ('node_examine_entity', {'text': '|gLoaded prototype test_prot.|n', 'back': 'index'}) ) @mock.patch("evennia.prototypes.menus.protlib.search_prototype", new=mock.MagicMock( From 994a5fd6184e448bafbb842068315649a092d555 Mon Sep 17 00:00:00 2001 From: Griatch Date: Mon, 30 Jul 2018 20:19:46 +0200 Subject: [PATCH 189/208] Correct unittests --- evennia/prototypes/tests.py | 62 +++++++++---------------------------- 1 file changed, 15 insertions(+), 47 deletions(-) diff --git a/evennia/prototypes/tests.py b/evennia/prototypes/tests.py index 9fb47585c9..71956efb91 100644 --- a/evennia/prototypes/tests.py +++ b/evennia/prototypes/tests.py @@ -74,7 +74,8 @@ class TestUtils(EvenniaTest): 'home': Something, 'key': 'Obj', 'location': Something, - 'locks': ['call:true()', + 'locks': ";".join([ + 'call:true()', 'control:perm(Developer)', 'delete:perm(Admin)', 'edit:perm(Admin)', @@ -82,7 +83,7 @@ class TestUtils(EvenniaTest): 'get:all()', 'puppet:pperm(Developer)', 'tell:perm(Admin)', - 'view:all()'], + 'view:all()']), 'prototype_desc': 'Built from Obj', 'prototype_key': Something, 'prototype_locks': 'spawn:all();edit:all()', @@ -132,15 +133,16 @@ class TestUtils(EvenniaTest): ('test', 'testval', None, [''])], 'prototype_locks': 'spawn:all();edit:all()', 'prototype_key': Something, - 'locks': ['call:true()', 'control:perm(Developer)', + 'locks': ";".join([ + 'call:true()', 'control:perm(Developer)', 'delete:perm(Admin)', 'edit:perm(Admin)', 'examine:perm(Builder)', 'get:all()', 'puppet:pperm(Developer)', 'tell:perm(Admin)', - 'view:all()'], + 'view:all()']), 'prototype_tags': [], - 'location': self.room1, + 'location': "#1", 'key': 'NewObj', - 'home': self.room1, + 'home': '#1', 'typeclass': 'evennia.objects.objects.DefaultObject', 'prototype_desc': 'Built from NewObj', 'aliases': 'foo'}) @@ -157,7 +159,8 @@ class TestUtils(EvenniaTest): 'home': Something, 'key': 'Obj', 'location': Something, - 'locks': ['call:true()', + 'locks': ";".join([ + 'call:true()', 'control:perm(Developer)', 'delete:perm(Admin)', 'edit:perm(Admin)', @@ -165,8 +168,8 @@ class TestUtils(EvenniaTest): 'get:all()', 'puppet:pperm(Developer)', 'tell:perm(Admin)', - 'view:all()'], - 'permissions': 'builder', + 'view:all()']), + 'permissions': ['builder'], 'prototype_desc': 'Built from Obj', 'prototype_key': Something, 'prototype_locks': 'spawn:all();edit:all()', @@ -184,7 +187,8 @@ class TestProtLib(EvenniaTest): def test_prototype_to_str(self): prstr = protlib.prototype_to_str(self.prot) - self.assertTrue(prstr.startswith("|cprototype key:|n")) + print("prst: {}".format(prstr)) + self.assertTrue(prstr.startswith("|cprototype-key:|n")) def test_check_permission(self): pass @@ -525,40 +529,4 @@ class TestOLCMenu(TestEvMenu): "node_index": "|c --- Prototype wizard --- |n" } - expected_tree = \ - ['node_index', - ['node_prototype_key', - ['node_index', - 'node_index', - 'node_validate_prototype', - ['node_index'], - 'node_index'], - 'node_typeclass', - ['node_key', - ['node_typeclass', - 'node_key', - 'node_index', - 'node_validate_prototype', - 'node_validate_prototype'], - 'node_index', - 'node_index', - 'node_index', - 'node_validate_prototype', - 'node_validate_prototype'], - 'node_validate_prototype', - 'node_validate_prototype', - 'node_validate_prototype', - 'node_validate_prototype', - 'node_validate_prototype', - 'node_validate_prototype', - 'node_validate_prototype', - 'node_validate_prototype', - 'node_validate_prototype', - 'node_validate_prototype', - 'node_validate_prototype', - 'node_validate_prototype', - 'node_validate_prototype', - 'node_validate_prototype', - 'node_validate_prototype', - 'node_validate_prototype', - 'node_validate_prototype']] + expected_tree = ['node_index', ['node_prototype_key', ['node_index', 'node_index', 'node_validate_prototype', ['node_index', 'node_index'], 'node_index'], 'node_prototype_parent', ['node_prototype_parent', 'node_prototype_key', 'node_prototype_parent', 'node_index', 'node_validate_prototype', 'node_index'], 'node_typeclass', ['node_typeclass', 'node_prototype_parent', 'node_typeclass', 'node_index', 'node_validate_prototype', 'node_index'], 'node_key', ['node_typeclass', 'node_key', 'node_index', 'node_validate_prototype', 'node_index'], 'node_aliases', ['node_key', 'node_aliases', 'node_index', 'node_validate_prototype', 'node_index'], 'node_attrs', ['node_aliases', 'node_attrs', 'node_index', 'node_validate_prototype', 'node_index'], 'node_tags', ['node_attrs', 'node_tags', 'node_index', 'node_validate_prototype', 'node_index'], 'node_locks', ['node_tags', 'node_locks', 'node_index', 'node_validate_prototype', 'node_index'], 'node_permissions', ['node_locks', 'node_permissions', 'node_index', 'node_validate_prototype', 'node_index'], 'node_location', ['node_permissions', 'node_location', 'node_index', 'node_validate_prototype', 'node_index', 'node_index'], 'node_home', ['node_location', 'node_home', 'node_index', 'node_validate_prototype', 'node_index', 'node_index'], 'node_destination', ['node_home', 'node_destination', 'node_index', 'node_validate_prototype', 'node_index', 'node_index'], 'node_prototype_desc', ['node_prototype_key', 'node_prototype_parent', 'node_index', 'node_validate_prototype', 'node_index'], 'node_prototype_tags', ['node_prototype_desc', 'node_prototype_tags', 'node_index', 'node_validate_prototype', 'node_index'], 'node_prototype_locks', ['node_examine_entity', ['node_prototype_locks', 'node_prototype_locks', 'node_prototype_locks'], 'node_examine_entity', 'node_prototype_locks', 'node_index', 'node_validate_prototype', 'node_index'], 'node_validate_prototype', 'node_index', 'node_prototype_spawn', ['node_index', 'node_validate_prototype'], 'node_index', 'node_search_object', ['node_index', 'node_index']]] From 7f9a2930ad3303ff3808217617c0b9072e1f07ea Mon Sep 17 00:00:00 2001 From: "Aris (Karim) Merchant" Date: Mon, 30 Jul 2018 16:37:22 -0700 Subject: [PATCH 190/208] Change callability check TypeErrors are thrown in a wide variety of situations, most of which have nothing to do with calling an uncallable object. The appropriate test is to use the built-in callable() function, which actually tests if the object is callable. --- evennia/commands/cmdset.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/evennia/commands/cmdset.py b/evennia/commands/cmdset.py index c363f87f80..6f127da1c2 100644 --- a/evennia/commands/cmdset.py +++ b/evennia/commands/cmdset.py @@ -296,9 +296,9 @@ class CmdSet(with_metaclass(_CmdSetMeta, object)): result (any): An instantiated Command or the input unmodified. """ - try: + if callable(cmd): return cmd() - except TypeError: + else: return cmd def _duplicate(self): From 5c88edcd71d73218afd9f55006a3e934ee66b8a2 Mon Sep 17 00:00:00 2001 From: Griatch Date: Tue, 31 Jul 2018 11:48:18 +0200 Subject: [PATCH 191/208] Cleanup menu style --- evennia/prototypes/menus.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/evennia/prototypes/menus.py b/evennia/prototypes/menus.py index 1141f27536..37911d7010 100644 --- a/evennia/prototypes/menus.py +++ b/evennia/prototypes/menus.py @@ -845,7 +845,8 @@ def node_typeclass(caller): {actions} """.format(current=_get_current_value(caller, "typeclass"), - actions=_format_list_actions("examine", "remove")) + actions="|WSelect with |w|W. Other actions: " + "|we|Wxamine |w|W, |wr|Wemove selection") helptext = """ A |nTypeclass|n is specified by the actual python-path to the class definition in the From 8721ac227e905cabc7cf2fc248e09b700f1dbcfb Mon Sep 17 00:00:00 2001 From: "Aris (Karim) Merchant" Date: Tue, 31 Jul 2018 12:24:45 -0700 Subject: [PATCH 192/208] Change Dockerfile to comply with best practices In order of decreasing significance: * Move addition of all files later to avoid premature build cache invalidation * Add separate instructions to copy over files needed earlier * Change deprecated MAINTAINER instruction to LABEL maintainer * Change ADD to COPY, as ADD apparently behaves weirdly in some cases * Alphabetize dependencies for readability --- Dockerfile | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/Dockerfile b/Dockerfile index 0c6b15103e..3f55973d70 100644 --- a/Dockerfile +++ b/Dockerfile @@ -21,22 +21,30 @@ # FROM alpine -MAINTAINER www.evennia.com +LABEL maintainer="www.evennia.com" # install compilation environment -RUN apk update && apk add python py-pip python-dev py-setuptools gcc \ -musl-dev jpeg-dev zlib-dev bash py2-openssl procps +RUN apk update && apk add bash gcc jpeg-dev musl-dev procps py-pip \ +py-setuptools py2-openssl python python-dev zlib-dev -# add the project source -ADD . /usr/src/evennia +# add the files required for pip installation +COPY ./setup.py /usr/src/evennia/ +COPY ./requirements.txt /usr/src/evennia/ +COPY ./evennia/VERSION.txt /usr/src/evennia/evennia/ +COPY ./bin /usr/src/evennia/bin/ # install dependencies RUN pip install -e /usr/src/evennia --trusted-host pypi.python.org RUN pip install cryptography pyasn1 service_identity +# add the project source; this should always be done after all +# expensive operations have completed to avoid prematurely +# invalidating the build cache. +COPY . /usr/src/evennia + # add the game source when rebuilding a new docker image from inside # a game dir -ONBUILD ADD . /usr/src/game +ONBUILD COPY . /usr/src/game # make the game source hierarchy persistent with a named volume. # mount on-disk game location here when using the container From 6e887758509632873040e84215b5b5b685905b81 Mon Sep 17 00:00:00 2001 From: Griatch Date: Fri, 10 Aug 2018 10:13:05 +0200 Subject: [PATCH 193/208] Create columnize (no ansi support at this point) --- evennia/accounts/manager.py | 1 - evennia/game_template/typeclasses/accounts.py | 1 - evennia/prototypes/menus.py | 29 ++++++-- evennia/utils/utils.py | 72 ++++++++++++++++++- 4 files changed, 92 insertions(+), 11 deletions(-) diff --git a/evennia/accounts/manager.py b/evennia/accounts/manager.py index c612cf930d..5d9bda2ab9 100644 --- a/evennia/accounts/manager.py +++ b/evennia/accounts/manager.py @@ -35,7 +35,6 @@ class AccountDBManager(TypedObjectManager, UserManager): get_account_from_uid get_account_from_name account_search (equivalent to evennia.search_account) - #swap_character """ diff --git a/evennia/game_template/typeclasses/accounts.py b/evennia/game_template/typeclasses/accounts.py index bbab3d4f22..99d861bf0b 100644 --- a/evennia/game_template/typeclasses/accounts.py +++ b/evennia/game_template/typeclasses/accounts.py @@ -65,7 +65,6 @@ class Account(DefaultAccount): * Helper methods msg(text=None, **kwargs) - swap_character(new_character, delete_old_character=False) execute_cmd(raw_string, session=None) search(ostring, global_search=False, attribute_name=None, use_nicks=False, location=None, ignore_errors=False, account=False) is_typeclass(typeclass, exact=False) diff --git a/evennia/prototypes/menus.py b/evennia/prototypes/menus.py index 37911d7010..8e88cad13c 100644 --- a/evennia/prototypes/menus.py +++ b/evennia/prototypes/menus.py @@ -57,6 +57,18 @@ def _get_flat_menu_prototype(caller, refresh=False, validate=False): return flat_prototype +def _get_unchanged_inherited(caller, protname): + """Return prototype values inherited from parent(s), which are not replaced in child""" + protototype = _get_menu_prototype(caller) + if protname in prototype: + return protname[protname], False + else: + flattened = _get_flat_menu_prototype(caller) + if protname in flattened: + return protname[protname], True + return None, False + + def _set_menu_prototype(caller, prototype): """Set the prototype with existing one""" caller.ndb._menutree.olc_prototype = prototype @@ -515,11 +527,11 @@ def node_index(caller): |c --- Prototype wizard --- |n A |cprototype|n is a 'template' for |wspawning|n an in-game entity. A field of the prototype - can be hard-coded or scripted using |w$protfuncs|n - for example to randomize the value - every time the prototype is used to spawn a new entity. + can either be hard-coded, left empty or scripted using |w$protfuncs|n - for example to + randomize the value every time a new entity is spawned. The fields whose names start with + 'Prototype-' are not fields on the object itself but are used for prototype-inheritance, or + when saving and loading. - The prototype fields whose names start with 'Prototype-' are not fields on the object itself - but are used in the template and when saving it for you (and maybe others) to use later. Select prototype field to edit. If you are unsure, start from [|w1|n]. Enter [|wh|n]elp at any menu node for more info. @@ -544,8 +556,8 @@ def node_index(caller): |c- $protfuncs |n Prototype-functions (protfuncs) allow for limited scripting within a prototype. These are - entered as a string $funcname(arg, arg, ...) and are evaluated |wat the time of spawning|n only. - They can also be nested for combined effects. + entered as a string $funcname(arg, arg, ...) and are evaluated |wat the time of spawning|n + only. They can also be nested for combined effects. {pfuncs} """.format(pfuncs=_format_protfuncs()) @@ -951,7 +963,10 @@ def node_aliases(caller): case sensitive. {actions} - """.format(actions=_format_list_actions("remove", prefix="|w|W to add new alias. Other action: ")) + {current} + """.format(actions=_format_list_actions("remove", + prefix="|w|W to add new alias. Other action: "), + current) helptext = """ Aliases are fixed alternative identifiers and are stored with the new object. diff --git a/evennia/utils/utils.py b/evennia/utils/utils.py index 3d07a82e9a..60d5c160d6 100644 --- a/evennia/utils/utils.py +++ b/evennia/utils/utils.py @@ -7,6 +7,7 @@ be of use when designing your own game. """ from __future__ import division, print_function +import itertools from builtins import object, range from future.utils import viewkeys, raise_ @@ -33,6 +34,7 @@ _MULTIMATCH_TEMPLATE = settings.SEARCH_MULTIMATCH_TEMPLATE _EVENNIA_DIR = settings.EVENNIA_DIR _GAME_DIR = settings.GAME_DIR + try: import cPickle as pickle except ImportError: @@ -210,18 +212,27 @@ def justify(text, width=None, align="f", indent=0): gap = " " # minimum gap between words if line_rest > 0: if align == 'l': - line[-1] += " " * line_rest + if line[-1] == "\n\n": + line[-1] = " " * (line_rest-1) + "\n" + " " * width + "\n" + " " * width + else: + line[-1] += " " * line_rest elif align == 'r': line[0] = " " * line_rest + line[0] elif align == 'c': pad = " " * (line_rest // 2) line[0] = pad + line[0] - line[-1] = line[-1] + pad + " " * (line_rest % 2) + if line[-1] == "\n\n": + line[-1] = line[-1] + pad + " " * (line_rest % 2) + else: + line[-1] = pad + " " * (line_rest % 2 - 1) + \ + "\n" + " " * width + "\n" + " " * width else: # align 'f' gap += " " * (line_rest // max(1, ngaps)) rest_gap = line_rest % max(1, ngaps) for i in range(rest_gap): line[i] += " " + elif not any(line): + return [" " * width] return gap.join(line) # split into paragraphs and words @@ -262,6 +273,62 @@ def justify(text, width=None, align="f", indent=0): return "\n".join([indentstring + line for line in lines]) +def columnize(string, columns=2, spacing=4, align='l', width=None): + """ + Break a string into a number of columns, using as little + vertical space as possible. + + Args: + string (str): The string to columnize. + columns (int, optional): The number of columns to use. + spacing (int, optional): How much space to have between columns. + width (int, optional): The max width of the columns. + Defaults to client's default width. + + Returns: + columns (str): Text divided into columns. + + Raises: + RuntimeError: If given invalid values. + + """ + columns = max(1, columns) + spacing = max(1, spacing) + width = width if width else settings.CLIENT_DEFAULT_WIDTH + + w_spaces = (columns - 1) * spacing + w_txt = max(1, width - w_spaces) + + if w_spaces + columns > width: # require at least 1 char per column + raise RuntimeError("Width too small to fit columns") + + colwidth = int(w_txt / (1.0 * columns)) + + # first make a single column which we then split + onecol = justify(string, width=colwidth, align=align) + onecol = onecol.split("\n") + + nrows, dangling = divmod(len(onecol), columns) + nrows = [nrows + 1 if i < dangling else nrows for i in range(columns)] + + height = max(nrows) + cols = [] + istart = 0 + for irows in nrows: + cols.append(onecol[istart:istart+irows]) + istart = istart + irows + for col in cols: + if len(col) < height: + col.append(" " * colwidth) + + sep = " " * spacing + rows = [] + for irow in range(height): + rows.append(sep.join(col[irow] for col in cols)) + + return "\n".join(rows) + + def list_to_string(inlist, endsep="and", addquote=False): """ This pretty-formats a list as string output, adding an optional @@ -1548,6 +1615,7 @@ def format_table(table, extra_space=1): Examples: ```python + ftable = format_table([[...], [...], ...]) for ir, row in enumarate(ftable): if ir == 0: # make first row white From c48868be1e59935958dc9fe01dffc17a6f64bdd5 Mon Sep 17 00:00:00 2001 From: Griatch Date: Sat, 11 Aug 2018 11:49:10 +0200 Subject: [PATCH 194/208] Cleanup/refactoring of olc menus --- CHANGELOG.md | 15 ++ evennia/prototypes/menus.py | 243 +++++++++++++++++++++---------- evennia/prototypes/prototypes.py | 18 +-- evennia/utils/evmenu.py | 7 +- evennia/utils/evmore.py | 10 +- evennia/utils/utils.py | 15 +- 6 files changed, 216 insertions(+), 92 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ae990b85b..37ff5bfef3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,8 +26,23 @@ - A `goto` option callable returning None (rather than the name of the next node) will now rerun the current node instead of failing. - Better error handling of in-node syntax errors. +- Improve dedent of default text/helptext formatter. Right-strip whitespace. +### Utils + +- Added new `columnize` function for easily splitting text into multiple columns. At this point it + is not working too well with ansi-colored text however. +- Extend the `dedent` function with a new `baseline_index` kwarg. This allows to force all lines to + the indentation given by the given line regardless of if other lines were already a 0 indentation. + This removes a problem with the original `textwrap.dedent` which will only dedent to the least + indented part of a text. +- Added `exit_cmd` to EvMore pager, to allow for calling a command (e.g. 'look') when leaving the pager. + +### Genaral + +- Start structuring the `CHANGELOG` to list features in more detail. + # Overviews diff --git a/evennia/prototypes/menus.py b/evennia/prototypes/menus.py index 8e88cad13c..c44cf0d5e2 100644 --- a/evennia/prototypes/menus.py +++ b/evennia/prototypes/menus.py @@ -214,6 +214,10 @@ def _wizard_options(curr_node, prev_node, next_node, color="|W", search=False): return options +def _set_actioninfo(caller, string): + caller.ndb._menutree.actioninfo = string + + def _path_cropper(pythonpath): "Crop path to only the last component" return pythonpath.split('.')[-1] @@ -278,30 +282,65 @@ def _format_list_actions(*args, **kwargs): prefix = kwargs.get('prefix', "|WSelect with |w|W. Other actions:|n ") for action in args: actions.append("|w{}|n|W{} |w|n".format(action[0], action[1:])) - return prefix + "|W,|n ".join(actions) + return prefix + " |W|||n ".join(actions) -def _get_current_value(caller, keyname, formatter=str, only_inherit=False): - "Return current value, marking if value comes from parent or set in this prototype" - prot = _get_menu_prototype(caller) - if keyname in prot: - # value in current prot +def _get_current_value(caller, keyname, comparer=None, formatter=str, only_inherit=False): + """ + Return current value, marking if value comes from parent or set in this prototype. + + Args: + keyname (str): Name of prototoype key to get current value of. + comparer (callable, optional): This will be called as comparer(prototype_value, + flattened_value) and is expected to return the value to show as the current + or inherited one. If not given, a straight comparison is used and what is returned + depends on the only_inherit setting. + formatter (callable, optional)): This will be called with the result of comparer. + only_inherit (bool, optional): If a current value should only be shown if all + the values are inherited from the prototype parent (otherwise, show an empty string). + Returns: + current (str): The current value. + + """ + def _default_comparer(protval, flatval): if only_inherit: - return '' - return "Current {}: {}".format(keyname, formatter(prot[keyname])) - flat_prot = _get_flat_menu_prototype(caller) - if keyname in flat_prot: - # value in flattened prot - if keyname == 'prototype_key': - # we don't inherit prototype_keys - return "[No prototype_key set] (|rnot inherited|n)" + return "" if protval else flatval else: - ret = "Current {} (|binherited|n): {}".format(keyname, formatter(flat_prot[keyname])) - if only_inherit: - return "{}\n\n".format(ret) - return ret + return protval if protval else flatval - return "[No {} set]".format(keyname) + if not callable(comparer): + comparer = _default_comparer + + prot = _get_menu_prototype(caller) + flat_prot = _get_flat_menu_prototype(caller) + + out = "" + if keyname in prot: + if keyname in flat_prot: + out = formatter(comparer(prot[keyname], flat_prot[keyname])) + if only_inherit: + if out: + return "|WCurrent|n {} |W(|binherited|W):|n {}".format(keyname, out) + return "" + else: + if out: + return "|WCurrent|n {}|W:|n {}".format(keyname, out) + return "|W[No {} set]|n".format(keyname) + elif only_inherit: + return "" + else: + out = formatter(prot[keyname]) + return "|WCurrent|n {}|W:|n {}".format(keyname, out) + elif keyname in flat_prot: + out = formatter(flat_prot[keyname]) + if out: + return "|WCurrent|n {} |W(|n|binherited|W):|n {}".format(keyname, out) + else: + return "" + elif only_inherit: + return "" + else: + return "|W[No {} set]|n".format(keyname) def _default_parse(raw_inp, choices, *args): @@ -491,10 +530,9 @@ def node_search_object(caller, raw_inp, **kwargs): text = """ Found {num} match{post}. - {actions} (|RWarning: creating a prototype will |roverwrite|r |Rthe current prototype!)|n""".format( - num=nmatches, post="es" if nmatches > 1 else "", - actions=_format_list_actions( + num=nmatches, post="es" if nmatches > 1 else "") + _set_actioninfo(caller, _format_list_actions( "examine", "create prototype from object", prefix="Actions: ")) else: text = "Enter search criterion." @@ -758,8 +796,6 @@ def node_prototype_parent(caller): parent is given, this prototype must define the typeclass (next menu node). {current} - - {actions} """ helptext = """ Prototypes can inherit from one another. Changes in the child replace any values set in a @@ -767,6 +803,8 @@ def node_prototype_parent(caller): prototype to be valid. """ + _set_actioninfo(caller, _format_list_actions("examine", "add", "remove")) + ptexts = [] if prot_parent_keys: for pkey in utils.make_iter(prot_parent_keys): @@ -782,8 +820,7 @@ def node_prototype_parent(caller): if not ptexts: ptexts.append("[No prototype_parent set]") - text = text.format(current="\n\n".join(ptexts), - actions=_format_list_actions("examine", "add", "remove")) + text = text.format(current="\n\n".join(ptexts)) text = (text, helptext) @@ -854,8 +891,6 @@ def node_typeclass(caller): one of the prototype's |cparents|n. {current} - - {actions} """.format(current=_get_current_value(caller, "typeclass"), actions="|WSelect with |w|W. Other actions: " "|we|Wxamine |w|W, |wr|Wemove selection") @@ -962,11 +997,15 @@ def node_aliases(caller): |cAliases|n are alternative ways to address an object, next to its |cKey|n. Aliases are not case sensitive. - {actions} {current} - """.format(actions=_format_list_actions("remove", - prefix="|w|W to add new alias. Other action: "), - current) + """.format(current=_get_current_value( + caller, 'aliases', + comparer=lambda propval, flatval: [al for al in flatval if al not in propval], + formatter=lambda lst: "\n" + ", ".join(lst), only_inherit=True)) + _set_actioninfo(caller, + _format_list_actions( + "remove", + prefix="|w|W to add new alias. Other action: ")) helptext = """ Aliases are fixed alternative identifiers and are stored with the new object. @@ -1009,14 +1048,13 @@ def _display_attribute(attr_tuple): attrkey, value, category, locks = attr_tuple value = protlib.protfunc_parser(value) typ = type(value) - out = ("|cAttribute key:|n '{attrkey}' " - "(|ccategory:|n {category}, " - "|clocks:|n {locks})\n" - "|cValue|n |W(parsed to {typ})|n:\n{value}").format( - attrkey=attrkey, - category=category if category else "|wNone|n", - locks=locks if locks else "|wNone|n", - typ=typ, value=value) + out = ("{attrkey} |c=|n {value} |W({typ}{category}{locks})|n".format( + attrkey=attrkey, + value=value, + typ=typ, + category=", category={}".format(category) if category else '', + locks=", locks={}".format(";".join(locks)) if any(locks) else '')) + return out @@ -1130,6 +1168,12 @@ def _attrs_actions(caller, raw_inp, **kwargs): @list_node(_caller_attrs, _attr_select) def node_attrs(caller): + def _currentcmp(propval, flatval): + "match by key + category" + cmp1 = [(tup[0].lower(), tup[2].lower() if tup[2] else None) for tup in propval] + return [tup for tup in flatval if (tup[0].lower(), tup[2].lower() + if tup[2] else None) not in cmp1] + text = """ |cAttributes|n are custom properties of the object. Enter attributes on one of these forms: @@ -1140,8 +1184,14 @@ def node_attrs(caller): To give an attribute without a category but with a lockstring, leave that spot empty (attrname;;lockstring=value). Attribute values can have embedded $protfuncs. - {actions} - """.format(actions=_format_list_actions("examine", "remove", prefix="Actions: ")) + {current} + """.format( + current=_get_current_value( + caller, "attrs", + comparer=_currentcmp, + formatter=lambda lst: "\n" + "\n".join(_display_attribute(tup) for tup in lst), + only_inherit=True)) + _set_actioninfo(caller, _format_list_actions("examine", "remove", prefix="Actions: ")) helptext = """ Most commonly, Attributes don't need any categories or locks. If using locks, the lock-types @@ -1290,6 +1340,13 @@ def _tags_actions(caller, raw_inp, **kwargs): @list_node(_caller_tags, _tag_select) def node_tags(caller): + + def _currentcmp(propval, flatval): + "match by key + category" + cmp1 = [(tup[0].lower(), tup[1].lower() if tup[2] else None) for tup in propval] + return [tup for tup in flatval if (tup[0].lower(), tup[1].lower() + if tup[1] else None) not in cmp1] + text = """ |cTags|n are used to group objects so they can quickly be found later. Enter tags on one of the following forms: @@ -1297,8 +1354,14 @@ def node_tags(caller): tagname;category tagname;category;data - {actions} - """.format(actions=_format_list_actions("examine", "remove", prefix="Actions: ")) + {current} + """.format( + current=_get_current_value( + caller, 'tags', + comparer=_currentcmp, + formatter=lambda lst: "\n" + "\n".join(_display_tag(tup) for tup in lst), + only_inherit=True)) + _set_actioninfo(caller, _format_list_actions("examine", "remove", prefix="Actions: ")) helptext = """ Tags are shared between all objects with that tag. So the 'data' field (which is not @@ -1325,18 +1388,7 @@ def _caller_locks(caller): def _locks_display(caller, lock): - try: - locktype, lockdef = lock.split(":", 1) - except ValueError: - txt = "Malformed lock string - Missing ':'" - else: - txt = ("{lockstr}\n\n" - "|WLocktype: |w{locktype}|n\n" - "|WLock def: |w{lockdef}|n\n").format( - lockstr=lock, - locktype=locktype, - lockdef=lockdef) - return txt + return lock def _lock_select(caller, lockstr): @@ -1395,6 +1447,11 @@ def _locks_actions(caller, raw_inp, **kwargs): @list_node(_caller_locks, _lock_select) def node_locks(caller): + def _currentcmp(propval, flatval): + "match by locktype" + cmp1 = [lck.split(":", 1)[0] for lck in propval.split(';')] + return ";".join(lstr for lstr in flatval.split(';') if lstr.split(':', 1)[0] not in cmp1) + text = """ The |cLock string|n defines limitations for accessing various properties of the object once it's spawned. The string should be on one of the following forms: @@ -1402,8 +1459,15 @@ def node_locks(caller): locktype:[NOT] lockfunc(args) locktype: [NOT] lockfunc(args) [AND|OR|NOT] lockfunc(args) [AND|OR|NOT] ... - {action} - """.format(action=_format_list_actions("examine", "remove", prefix="Actions: ")) + {current}{action} + """.format( + current=_get_current_value( + caller, 'locks', + comparer=_currentcmp, + formatter=lambda lockstr: "\n".join(_locks_display(caller, lstr) + for lstr in lockstr.split(';')), + only_inherit=True), + action=_format_list_actions("examine", "remove", prefix="Actions: ")) helptext = """ Here is an example of two lock strings: @@ -1438,16 +1502,17 @@ def _caller_permissions(caller): return perms -def _display_perm(caller, permission): +def _display_perm(caller, permission, only_hierarchy=False): hierarchy = settings.PERMISSION_HIERARCHY perm_low = permission.lower() + txt = '' if perm_low in [prm.lower() for prm in hierarchy]: txt = "Permission (in hieararchy): {}".format( ", ".join( ["|w[{}]|n".format(prm) if prm.lower() == perm_low else "|W{}|n".format(prm) for prm in hierarchy])) - else: + elif not only_hierarchy: txt = "Permission: '{}'".format(permission) return txt @@ -1500,12 +1565,23 @@ def _permissions_actions(caller, raw_inp, **kwargs): @list_node(_caller_permissions, _permission_select) def node_permissions(caller): + def _currentcmp(pval, fval): + cmp1 = [perm.lower() for perm in pval] + return [perm for perm in fval if perm.lower() not in cmp1] + text = """ |cPermissions|n are simple strings used to grant access to this object. A permission is used - when a |clock|n is checked that contains the |wperm|n or |wpperm|n lock functions. + when a |clock|n is checked that contains the |wperm|n or |wpperm|n lock functions. Certain + permissions belong in the |cpermission hierarchy|n together with the |Wperm()|n lock + function. - {actions} - """.format(actions=_format_list_actions("examine", "remove"), prefix="Actions: ") + {current} + """.format( + current=_get_current_value( + caller, 'permissions', + comparer=_currentcmp, + formatter=lambda lst: "\n" + "\n".join(prm for prm in lst), only_inherit=True)) + _set_actioninfo(caller, _format_list_actions("examine", "remove", prefix="Actions: ")) helptext = """ Any string can act as a permission as long as a lock is set to look for it. Depending on the @@ -1538,7 +1614,6 @@ def node_location(caller): inventory of |c{caller}|n by default. {current} - """.format(caller=caller.key, current=_get_current_value(caller, "location")) helptext = """ @@ -1734,9 +1809,13 @@ def node_prototype_tags(caller): |cPrototype-Tags|n can be used to classify and find prototypes in listings Tag names are not case-sensitive and can have not have a custom category. - {actions} - """.format(actions=_format_list_actions( - "remove", prefix="|w|n|W to add Tag. Other Action:|n ")) + {current} + """.format( + current=_get_current_value( + caller, 'prototype_tags', + formatter=lambda lst: ", ".join(tg for tg in lst), only_inherit=True)) + _set_actioninfo(caller, _format_list_actions( + "remove", prefix="|w|n|W to add Tag. Other Action:|n ")) helptext = """ Using prototype-tags is a good way to organize and group large numbers of prototypes by genre, type etc. Under the hood, prototypes' tags will all be stored with the category @@ -1827,8 +1906,14 @@ def node_prototype_locks(caller): If unsure, keep the open defaults. - {actions} - """.format(actions=_format_list_actions('examine', "remove", prefix="Actions: ")) + {current} + """.format( + current=_get_current_value( + caller, 'prototype_locks', + formatter=lambda lstring: "\n".join(_locks_display(caller, lstr) + for lstr in lstring.split(';')), + only_inherit=True)) + _set_actioninfo(caller, _format_list_actions('examine', "remove", prefix="Actions: ")) helptext = """ Prototype locks can be used to vary access for different tiers of builders. It also allows @@ -2204,9 +2289,8 @@ def node_prototype_load(caller, **kwargs): text = """ Select a prototype to load. This will replace any prototype currently being edited! - - {actions} - """.format(actions=_format_list_actions("examine", "delete")) + """ + _set_actioninfo(caller, _format_list_actions("examine", "delete")) helptext = """ Loading a prototype will load it and return you to the main index. It can be a good idea @@ -2230,6 +2314,13 @@ class OLCMenu(EvMenu): A custom EvMenu with a different formatting for the options. """ + def nodetext_formatter(self, nodetext): + """ + Format the node text itself. + + """ + return super(OLCMenu, self).nodetext_formatter(nodetext) + def options_formatter(self, optionlist): """ Split the options into two blocks - olc options and normal options @@ -2237,6 +2328,7 @@ class OLCMenu(EvMenu): """ olc_keys = ("index", "forward", "back", "previous", "next", "validate prototype", "save prototype", "load prototype", "spawn prototype", "search objects") + actioninfo = self.actioninfo + "\n" if hasattr(self, 'actioninfo') else '' olc_options = [] other_options = [] for key, desc in optionlist: @@ -2247,7 +2339,8 @@ class OLCMenu(EvMenu): else: other_options.append((key, desc)) - olc_options = " | ".join(olc_options) + " | " + "|wQ|Wuit" if olc_options else "" + olc_options = actioninfo + \ + " |W|||n ".join(olc_options) + " |W|||n " + "|wQ|Wuit" if olc_options else "" other_options = super(OLCMenu, self).options_formatter(other_options) sep = "\n\n" if olc_options and other_options else "" @@ -2257,10 +2350,10 @@ class OLCMenu(EvMenu): """ Show help text """ - return "|c --- Help ---|n\n" + helptext + return "|c --- Help ---|n\n" + utils.dedent(helptext) def display_helptext(self): - evmore.msg(self.caller, self.helptext, session=self._session) + evmore.msg(self.caller, self.helptext, session=self._session, exit_cmd='look') def start_olc(caller, session=None, prototype=None): diff --git a/evennia/prototypes/prototypes.py b/evennia/prototypes/prototypes.py index 4c53ed7d1c..0cc016300f 100644 --- a/evennia/prototypes/prototypes.py +++ b/evennia/prototypes/prototypes.py @@ -192,16 +192,14 @@ def prototype_to_str(prototype): category = "|ccategory:|n {}".format(category) if category else '' cat_locks = "" if category or locks: - cat_locks = "(|ccategory:|n {category}, ".format( + cat_locks = " (|ccategory:|n {category}, ".format( category=category if category else "|wNone|n") out.append( - "{attrkey} " - "{cat_locks}\n" - " |c=|n {value}".format( - attrkey=attrkey, - cat_locks=cat_locks, - locks=locks if locks else "|wNone|n", - value=value)) + "{attrkey}{cat_locks} |c=|n {value}".format( + attrkey=attrkey, + cat_locks=cat_locks, + locks=locks if locks else "|wNone|n", + value=value)) attrs = "|cattrs:|n\n {attrs}".format(attrs="\n ".join(out)) tags = prototype.get('tags', '') if tags: @@ -209,10 +207,10 @@ def prototype_to_str(prototype): for (tagkey, category, data) in tags: out.append("{tagkey} (category: {category}{dat})".format( tagkey=tagkey, category=category, dat=", data: {}".format(data) if data else "")) - tags = "|ctags:|n\n {tags}".format(tags="\n ".join(out)) + tags = "|ctags:|n\n {tags}".format(tags=", ".join(out)) locks = prototype.get('locks', '') if locks: - locks = "|clocks:|n\n {locks}".format(locks="\n ".join(locks.split(";"))) + locks = "|clocks:|n\n {locks}".format(locks=locks) permissions = prototype.get("permissions", '') if permissions: permissions = "|cpermissions:|n {perms}".format(perms=", ".join(permissions)) diff --git a/evennia/utils/evmenu.py b/evennia/utils/evmenu.py index 638f4eef6e..0297170da2 100644 --- a/evennia/utils/evmenu.py +++ b/evennia/utils/evmenu.py @@ -167,14 +167,13 @@ from __future__ import print_function import random from builtins import object, range -from textwrap import dedent from inspect import isfunction, getargspec from django.conf import settings from evennia import Command, CmdSet from evennia.utils import logger from evennia.utils.evtable import EvTable from evennia.utils.ansi import strip_ansi -from evennia.utils.utils import mod_import, make_iter, pad, m_len, is_iter +from evennia.utils.utils import mod_import, make_iter, pad, m_len, is_iter, dedent from evennia.commands import cmdhandler # read from protocol NAWS later? @@ -896,7 +895,7 @@ class EvMenu(object): nodetext (str): The formatted node text. """ - return dedent(nodetext).strip() + return dedent(nodetext.strip('\n'), baseline_index=0).rstrip() def helptext_formatter(self, helptext): """ @@ -909,7 +908,7 @@ class EvMenu(object): helptext (str): The formatted help text. """ - return dedent(helptext).strip() + return dedent(helptext.strip('\n'), baseline_index=0).rstrip() def options_formatter(self, optionlist): """ diff --git a/evennia/utils/evmore.py b/evennia/utils/evmore.py index e0ec091005..94173b9eca 100644 --- a/evennia/utils/evmore.py +++ b/evennia/utils/evmore.py @@ -122,7 +122,8 @@ class EvMore(object): """ def __init__(self, caller, text, always_page=False, session=None, - justify_kwargs=None, exit_on_lastpage=False, **kwargs): + justify_kwargs=None, exit_on_lastpage=False, + exit_cmd=None, **kwargs): """ Initialization of the text handler. @@ -141,6 +142,10 @@ class EvMore(object): page being completely filled, exit pager immediately. If unset, another move forward is required to exit. If set, the pager exit message will not be shown. + exit_cmd (str, optional): If given, this command-string will be executed on + the caller when the more page exits. Note that this will be using whatever + cmdset the user had *before* the evmore pager was activated (so none of + the evmore commands will be available when this is run). kwargs (any, optional): These will be passed on to the `caller.msg` method. @@ -151,6 +156,7 @@ class EvMore(object): self._npages = [] self._npos = [] self.exit_on_lastpage = exit_on_lastpage + self.exit_cmd = exit_cmd self._exit_msg = "Exited |wmore|n pager." if not session: # if not supplied, use the first session to @@ -269,6 +275,8 @@ class EvMore(object): if not quiet: self._caller.msg(text=self._exit_msg, **self._kwargs) self._caller.cmdset.remove(CmdSetMore) + if self.exit_cmd: + self._caller.execute_cmd(self.exit_cmd, session=self._session) def msg(caller, text="", always_page=False, session=None, diff --git a/evennia/utils/utils.py b/evennia/utils/utils.py index 60d5c160d6..abe7d3c1e3 100644 --- a/evennia/utils/utils.py +++ b/evennia/utils/utils.py @@ -160,12 +160,16 @@ def crop(text, width=None, suffix="[...]"): return to_str(utext) -def dedent(text): +def dedent(text, baseline_index=None): """ Safely clean all whitespace at the left of a paragraph. Args: text (str): The text to dedent. + baseline_index (int or None, optional): Which row to use as a 'base' + for the indentation. Lines will be dedented to this level but + no further. If None, indent so as to completely deindent the + least indented text. Returns: text (str): Dedented string. @@ -178,7 +182,14 @@ def dedent(text): """ if not text: return "" - return textwrap.dedent(text) + if baseline_index is None: + return textwrap.dedent(text) + else: + lines = text.split('\n') + baseline = lines[baseline_index] + spaceremove = len(baseline) - len(baseline.lstrip(' ')) + return "\n".join(line[min(spaceremove, len(line) - len(line.lstrip(' '))):] + for line in lines) def justify(text, width=None, align="f", indent=0): From 6cf6476417cef6a0434dba9ef595d84eba623f8b Mon Sep 17 00:00:00 2001 From: Griatch Date: Sun, 12 Aug 2018 13:13:13 +0200 Subject: [PATCH 195/208] Fix further bugs in menu spawn --- evennia/prototypes/menus.py | 31 ++++++++++++++++++------------- 1 file changed, 18 insertions(+), 13 deletions(-) diff --git a/evennia/prototypes/menus.py b/evennia/prototypes/menus.py index c44cf0d5e2..0e4f59ffbc 100644 --- a/evennia/prototypes/menus.py +++ b/evennia/prototypes/menus.py @@ -1953,12 +1953,12 @@ def _keep_diff(caller, **kwargs): def node_apply_diff(caller, **kwargs): """Offer options for updating objects""" - def _keep_option(keyname, prototype, obj, obj_prototype, diff, objects, back_node): + def _keep_option(keyname, prototype, base_obj, obj_prototype, diff, objects, back_node): """helper returning an option dict""" options = {"desc": "Keep {} as-is".format(keyname), "goto": (_keep_diff, {"key": keyname, "prototype": prototype, - "obj": obj, "obj_prototype": obj_prototype, + "base_obj": base_obj, "obj_prototype": obj_prototype, "diff": diff, "objects": objects, "back_node": back_node})} return options @@ -1966,6 +1966,7 @@ def node_apply_diff(caller, **kwargs): update_objects = kwargs.get("objects", None) back_node = kwargs.get("back_node", "node_index") obj_prototype = kwargs.get("obj_prototype", None) + base_obj = kwargs.get("base_obj", None) diff = kwargs.get("diff", None) if not update_objects: @@ -1976,12 +1977,12 @@ def node_apply_diff(caller, **kwargs): if not diff: # use one random object as a reference to calculate a diff - obj = choice(update_objects) - diff, obj_prototype = spawner.prototype_diff_from_object(prototype, obj) + base_obj = choice(update_objects) + diff, obj_prototype = spawner.prototype_diff_from_object(prototype, base_obj) text = ["Suggested changes to {} objects. ".format(len(update_objects)), "Showing random example obj to change: {name} ({dbref}))\n".format( - name=obj.key, dbref=obj.dbref)] + name=base_obj.key, dbref=base_obj.dbref)] helptext = """ Be careful with this operation! The upgrade mechanism will try to automatically estimate @@ -2001,7 +2002,7 @@ def node_apply_diff(caller, **kwargs): continue line = "{iopt} |w{key}|n: {old}{sep}{new} {change}" - old_val = utils.crop(str(obj_prototype[key]), width=20) + old_val = str(obj_prototype.get(key, "")) if inst == "KEEP": inst = "|b{}|n".format(inst) @@ -2009,25 +2010,29 @@ def node_apply_diff(caller, **kwargs): sep=" ", new='', change=inst)) continue - new_val = utils.crop(str(spawner.init_spawn_value(prototype[key])), width=20) + if key in prototype: + new_val = str(spawner.init_spawn_value(prototype[key])) + else: + new_val = "" ichanges += 1 if inst in ("UPDATE", "REPLACE"): inst = "|y{}|n".format(inst) text.append(line.format(iopt=ichanges, key=key, old=old_val, sep=" |y->|n ", new=new_val, change=inst)) options.append(_keep_option(key, prototype, - obj, obj_prototype, diff, update_objects, back_node)) + base_obj, obj_prototype, diff, update_objects, back_node)) elif inst == "REMOVE": inst = "|r{}|n".format(inst) text.append(line.format(iopt=ichanges, key=key, old=old_val, sep=" |r->|n ", new='', change=inst)) options.append(_keep_option(key, prototype, - obj, obj_prototype, diff, update_objects, back_node)) + base_obj, obj_prototype, diff, update_objects, back_node)) options.extend( - [{"key": ("|wu|Wupdate {} objects".format(len(update_objects)), "update", "u"), - "goto": (_apply_diff, {"prototye": prototype, "objects": update_objects, - "back_node": back_node, "diff": diff})}, - {"key": ("|wr|Wneset changes", "reset", "r"), + [{"key": ("|wu|Wpdate {} objects".format(len(update_objects)), "update", "u"), + "desc": "Update {} objects".format(len(update_objects)), + "goto": (_apply_diff, {"prototype": prototype, "objects": update_objects, + "back_node": back_node, "diff": diff, "base_obj": base_obj})}, + {"key": ("|wr|Weset changes", "reset", "r"), "goto": ("node_apply_diff", {"prototype": prototype, "back_node": back_node, "objects": update_objects})}]) From 7dec566926efa6b85933b7aa8448bcbcf09666a8 Mon Sep 17 00:00:00 2001 From: Griatch Date: Sun, 12 Aug 2018 13:37:19 +0200 Subject: [PATCH 196/208] Resolve unittests --- evennia/prototypes/tests.py | 1 - evennia/utils/utils.py | 7 +++---- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/evennia/prototypes/tests.py b/evennia/prototypes/tests.py index 71956efb91..1c77fd85c3 100644 --- a/evennia/prototypes/tests.py +++ b/evennia/prototypes/tests.py @@ -187,7 +187,6 @@ class TestProtLib(EvenniaTest): def test_prototype_to_str(self): prstr = protlib.prototype_to_str(self.prot) - print("prst: {}".format(prstr)) self.assertTrue(prstr.startswith("|cprototype-key:|n")) def test_check_permission(self): diff --git a/evennia/utils/utils.py b/evennia/utils/utils.py index abe7d3c1e3..cd6c57a21f 100644 --- a/evennia/utils/utils.py +++ b/evennia/utils/utils.py @@ -7,7 +7,6 @@ be of use when designing your own game. """ from __future__ import division, print_function -import itertools from builtins import object, range from future.utils import viewkeys, raise_ @@ -233,10 +232,10 @@ def justify(text, width=None, align="f", indent=0): pad = " " * (line_rest // 2) line[0] = pad + line[0] if line[-1] == "\n\n": - line[-1] = line[-1] + pad + " " * (line_rest % 2) - else: - line[-1] = pad + " " * (line_rest % 2 - 1) + \ + line[-1] += pad + " " * (line_rest % 2 - 1) + \ "\n" + " " * width + "\n" + " " * width + else: + line[-1] = line[-1] + pad + " " * (line_rest % 2) else: # align 'f' gap += " " * (line_rest // max(1, ngaps)) rest_gap = line_rest % max(1, ngaps) From 8e1928a9480164f8e365f369714fabb8ef467d1f Mon Sep 17 00:00:00 2001 From: Griatch Date: Sun, 12 Aug 2018 14:58:12 +0200 Subject: [PATCH 197/208] Update changelog and readme with current changes --- CHANGELOG.md | 28 ++++++++++++++++++++++++++-- evennia/contrib/README.md | 4 ++++ 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 37ff5bfef3..d876b419d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,9 +2,20 @@ ## Evennia 0.8 (2018) +### Server/Portal + +- Removed `evennia_runner`, completely refactor `evennia_launcher.py` (the 'evennia' program) + with different functionality). +- Both Portal/Server are now stand-alone processes (easy to run as daemon) +- Made Portal the AMP Server for starting/restarting the Server (the AMP client) +- Dynamic logging now happens using `evennia -l` rather than by interactive. +- Made AMP secure against erroneous HTTP requests on the wrong port (return error messages). + ### Prototype changes -- A new form of prototype - database-stored prototypes, editable from in-game. The old, +- Moved evennia/utils/spawner.py into the new evennia/prototypes/ along with all new + functionality around prototypes. +- A new form of prototype - database-stored prototypes, editable from in-game, was added. The old, module-created prototypes remain as read-only prototypes. - All prototypes must have a key `prototype_key` identifying the prototype in listings. This is checked to be server-unique. Prototypes created in a module will use the global variable name they @@ -39,10 +50,23 @@ indented part of a text. - Added `exit_cmd` to EvMore pager, to allow for calling a command (e.g. 'look') when leaving the pager. -### Genaral +### General - Start structuring the `CHANGELOG` to list features in more detail. +### Contribs + +- `Health Bar` (Tim Ashley Jenkins): Easily create colorful bars/meters. +- `Tree select` (Fluttersprite): Wrapper around EvMenu to easier create + a common form of menu from a string. +- `Turnbattle suite` (Tim Ashley Jenkins)- the old `turnbattle.py` was moved into its own + `turnbattle/` package and reworked with many different flavors of combat systems: + - `tb_basic` - The basic turnbattle system, with initiative/turn order attack/defense/damage. + - `tb_equip` - Adds weapon and armor, wielding, accuracy modifiers. + - `tb_items` - Extends `tb_equip` with item use with conditions/status effects. + - `tb_magic` - Extends `tb_equip` with spellcasting. + - `tb_range` - Adds system for abstract positioning and movement. +- Updates and some cleanup of existing contribs. # Overviews diff --git a/evennia/contrib/README.md b/evennia/contrib/README.md index 5ca11b1799..4785be6197 100644 --- a/evennia/contrib/README.md +++ b/evennia/contrib/README.md @@ -31,6 +31,7 @@ things you want from here into your game folder and change them there. multiple descriptions for time and season as well as details. * GenderSub (Griatch 2015) - Simple example (only) of storing gender on a character and access it in an emote with a custom marker. +* Health Bar (Tim Ashley Jenkins 2017) - Tool to create colorful bars/meters. * Mail (grungies1138 2016) - An in-game mail system for communication. * Menu login (Griatch 2011) - A login system using menus asking for name/password rather than giving them as one command. @@ -53,6 +54,9 @@ things you want from here into your game folder and change them there. * Tree Select (FlutterSprite 2017) - A simple system for creating a branching EvMenu with selection options sourced from a single multi-line string. +* Turnbattle (Tim Ashley Jenkins 2017) - This is a framework for a turn-based + combat system with different levels of complexity, including versions with + equipment and magic as well as ranged combat. * Wilderness (titeuf87 2017) - Make infinitely large wilderness areas with dynamically created locations. * UnixCommand (Vincent Le Geoff 2017) - Add commands with UNIX-style syntax. From 5f5028af4118ce2143bbdc25c259e5d9aab3721c Mon Sep 17 00:00:00 2001 From: Griatch Date: Sun, 12 Aug 2018 15:06:16 +0200 Subject: [PATCH 198/208] Correct spawner import in contrib --- evennia/contrib/turnbattle/tb_items.py | 182 ++++++++++++------------- 1 file changed, 91 insertions(+), 91 deletions(-) diff --git a/evennia/contrib/turnbattle/tb_items.py b/evennia/contrib/turnbattle/tb_items.py index d0e9fe8e34..cfb511b4ad 100644 --- a/evennia/contrib/turnbattle/tb_items.py +++ b/evennia/contrib/turnbattle/tb_items.py @@ -28,7 +28,7 @@ This module includes a number of example conditions: Haste: +1 action per turn Paralyzed: No actions per turn Frightened: Character can't use the 'attack' command - + Since conditions can have a wide variety of effects, their code is scattered throughout the other functions wherever they may apply. @@ -70,7 +70,7 @@ from random import randint from evennia import DefaultCharacter, Command, default_cmds, DefaultScript from evennia.commands.default.muxcommand import MuxCommand from evennia.commands.default.help import CmdHelp -from evennia.utils.spawner import spawn +from evennia.prototypes.spawner import spawn from evennia import TICKER_HANDLER as tickerhandler """ @@ -230,10 +230,10 @@ def apply_damage(defender, damage): def at_defeat(defeated): """ Announces the defeat of a fighter in combat. - + Args: defeated (obj): Fighter that's been defeated. - + Notes: All this does is announce a defeat message by default, but if you want anything else to happen to defeated fighters (like putting them @@ -250,7 +250,7 @@ def resolve_attack(attacker, defender, attack_value=None, defense_value=None, Args: attacker (obj): Character doing the attacking defender (obj): Character being attacked - + Options: attack_value (int): Override for attack roll defense_value (int): Override for defense value @@ -353,11 +353,11 @@ def spend_action(character, actions, action_name=None): def spend_item_use(item, user): """ Spends one use on an item with limited uses. - + Args: item (obj): Item being used user (obj): Character using the item - + Notes: If item.db.item_consumable is 'True', the item is destroyed if it runs out of uses - if it's a string instead of 'True', it will also @@ -365,32 +365,32 @@ def spend_item_use(item, user): as the name of the prototype to spawn. """ item.db.item_uses -= 1 # Spend one use - + if item.db.item_uses > 0: # Has uses remaining # Inform the player user.msg("%s has %i uses remaining." % (item.key.capitalize(), item.db.item_uses)) - + else: # All uses spent - + if not item.db.item_consumable: # Item isn't consumable # Just inform the player that the uses are gone user.msg("%s has no uses remaining." % item.key.capitalize()) - + else: # If item is consumable if item.db.item_consumable == True: # If the value is 'True', just destroy the item user.msg("%s has been consumed." % item.key.capitalize()) item.delete() # Delete the spent item - + else: # If a string, use value of item_consumable to spawn an object in its place residue = spawn({"prototype":item.db.item_consumable})[0] # Spawn the residue residue.location = item.location # Move the residue to the same place as the item user.msg("After using %s, you are left with %s." % (item, residue)) item.delete() # Delete the spent item - + def use_item(user, item, target): """ Performs the action of using an item. - + Args: user (obj): Character using the item item (obj): Item being used @@ -399,53 +399,53 @@ def use_item(user, item, target): # If item is self only and no target given, set target to self. if item.db.item_selfonly and target == None: target = user - + # If item is self only, abort use if used on others. if item.db.item_selfonly and user != target: user.msg("%s can only be used on yourself." % item) return - + # Set kwargs to pass to item_func kwargs = {} - if item.db.item_kwargs: - kwargs = item.db.item_kwargs - + if item.db.item_kwargs: + kwargs = item.db.item_kwargs + # Match item_func string to function try: item_func = ITEMFUNCS[item.db.item_func] except KeyError: # If item_func string doesn't match to a function in ITEMFUNCS user.msg("ERROR: %s not defined in ITEMFUNCS" % item.db.item_func) return - + # Call the item function - abort if it returns False, indicating an error. # This performs the actual action of using the item. # Regardless of what the function returns (if anything), it's still executed. if item_func(item, user, target, **kwargs) == False: return - + # If we haven't returned yet, we assume the item was used successfully. # Spend one use if item has limited uses if item.db.item_uses: spend_item_use(item, user) - + # Spend an action if in combat if is_in_combat(user): spend_action(user, 1, action_name="item") - + def condition_tickdown(character, turnchar): """ Ticks down the duration of conditions on a character at the start of a given character's turn. - + Args: character (obj): Character to tick down the conditions of turnchar (obj): Character whose turn it currently is - + Notes: In combat, this is called on every fighter at the start of every character's turn. Out of combat, it's instead called when a character's at_update() hook is called, which is every 30 seconds by default. """ - + for key in character.db.conditions: # The first value is the remaining turns - the second value is whose turn to count down on. condition_duration = character.db.conditions[key][0] @@ -459,11 +459,11 @@ def condition_tickdown(character, turnchar): # If the duration is brought down to 0, remove the condition and inform everyone. character.location.msg_contents("%s no longer has the '%s' condition." % (str(character), str(key))) del character.db.conditions[key] - + def add_condition(character, turnchar, condition, duration): """ Adds a condition to a fighter. - + Args: character (obj): Character to give the condition to turnchar (obj): Character whose turn to tick down the condition on in combat @@ -501,7 +501,7 @@ class TBItemsCharacter(DefaultCharacter): """ Adds attributes for a character's current and maximum HP. We're just going to set this value at '100' by default. - + An empty dictionary is created to store conditions later, and the character is subscribed to the Ticker Handler, which will call at_update() on the character, with the interval @@ -536,17 +536,17 @@ class TBItemsCharacter(DefaultCharacter): self.msg("You can't move, you've been defeated!") return False return True - + def at_turn_start(self): """ Hook called at the beginning of this character's turn in combat. """ # Prompt the character for their turn and give some information. self.msg("|wIt's your turn! You have %i HP remaining.|n" % self.db.hp) - + # Apply conditions that fire at the start of each turn. self.apply_turn_conditions() - + def apply_turn_conditions(self): """ Applies the effect of conditions that occur at the start of each @@ -559,7 +559,7 @@ class TBItemsCharacter(DefaultCharacter): to_heal = self.db.max_hp - self.db.hp # Cap healing to max HP self.db.hp += to_heal self.location.msg_contents("%s regains %i HP from Regeneration." % (self, to_heal)) - + # Poisoned: does 4 to 8 damage at the start of character's turn if "Poisoned" in self.db.conditions: to_hurt = randint(POISON_RATE[0], POISON_RATE[1]) # Deal damage @@ -568,18 +568,18 @@ class TBItemsCharacter(DefaultCharacter): if self.db.hp <= 0: # Call at_defeat if poison defeats the character at_defeat(self) - + # Haste: Gain an extra action in combat. if is_in_combat(self) and "Haste" in self.db.conditions: self.db.combat_actionsleft += 1 self.msg("You gain an extra action this turn from Haste!") - + # Paralyzed: Have no actions in combat. if is_in_combat(self) and "Paralyzed" in self.db.conditions: self.db.combat_actionsleft = 0 self.location.msg_contents("%s is Paralyzed, and can't act this turn!" % self) self.db.combat_turnhandler.turn_end_check(self) - + def at_update(self): """ Fires every 30 seconds. @@ -602,7 +602,7 @@ class TBItemsCharacterTest(TBItemsCharacter): self.db.max_hp = 100 # Set maximum HP to 100 self.db.hp = self.db.max_hp # Set current HP to maximum self.db.conditions = {} # Set empty dict for conditions - + """ ---------------------------------------------------------------------------- @@ -651,7 +651,7 @@ class TBItemsTurnHandler(DefaultScript): # Announce the turn order. self.obj.msg_contents("Turn order is: %s " % ", ".join(obj.key for obj in self.db.fighters)) - + # Start first fighter's turn. self.start_turn(self.db.fighters[0]) @@ -748,14 +748,14 @@ class TBItemsTurnHandler(DefaultScript): self.db.turn += 1 # Go to the next in the turn order. if self.db.turn > len(self.db.fighters) - 1: self.db.turn = 0 # Go back to the first in the turn order once you reach the end. - + newchar = self.db.fighters[self.db.turn] # Note the new character - + self.db.timer = TURN_TIMEOUT + self.time_until_next_repeat() # Reset the timer. self.db.timeout_warning_given = False # Reset the timeout warning. self.obj.msg_contents("%s's turn ends - %s's turn begins!" % (currentchar, newchar)) self.start_turn(newchar) # Start the new character's turn. - + # Count down condition timers. for fighter in self.db.fighters: condition_tickdown(fighter, newchar) @@ -785,7 +785,7 @@ class TBItemsTurnHandler(DefaultScript): # Initialize the character like you do at the start. self.initialize_for_combat(character) - + """ ---------------------------------------------------------------------------- COMMANDS START HERE @@ -865,7 +865,7 @@ class CmdAttack(Command): if not self.caller.db.hp: # Can't attack if you have no HP. self.caller.msg("You can't attack, you've been defeated.") return - + if "Frightened" in self.caller.db.conditions: # Can't attack if frightened self.caller.msg("You're too frightened to attack!") return @@ -1033,29 +1033,29 @@ class CmdUse(MuxCommand): item = self.caller.search(self.lhs, candidates=self.caller.contents) if not item: return - + # Search for target, if any is given target = None if self.rhs: target = self.caller.search(self.rhs) if not target: return - + # If in combat, can only use items on your turn if is_in_combat(self.caller): if not is_turn(self.caller): self.caller.msg("You can only use items on your turn.") return - + if not item.db.item_func: # Object has no item_func, not usable self.caller.msg("'%s' is not a usable item." % item.key.capitalize()) return - + if item.attributes.has("item_uses"): # Item has limited uses if item.db.item_uses <= 0: # Limited uses are spent self.caller.msg("'%s' has no uses remaining." % item.key.capitalize()) return - + # If everything checks out, call the use_item function use_item(self.caller, item, target) @@ -1077,7 +1077,7 @@ class BattleCmdSet(default_cmds.CharacterCmdSet): self.add(CmdDisengage()) self.add(CmdCombatHelp()) self.add(CmdUse()) - + """ ---------------------------------------------------------------------------- ITEM FUNCTIONS START HERE @@ -1091,7 +1091,7 @@ Every item function must take the following arguments: item (obj): The item being used user (obj): The character using the item target (obj): The target of the item use - + Item functions must also accept **kwargs - these keyword arguments can be used to define how different items that use the same function can have different effects (for example, different attack items doing different @@ -1104,25 +1104,25 @@ take and the effect they have on the result. def itemfunc_heal(item, user, target, **kwargs): """ Item function that heals HP. - + kwargs: min_healing(int): Minimum amount of HP recovered max_healing(int): Maximum amount of HP recovered """ - if not target: + if not target: target = user # Target user if none specified - + if not target.attributes.has("max_hp"): # Has no HP to speak of user.msg("You can't use %s on that." % item) return False # Returning false aborts the item use - + if target.db.hp >= target.db.max_hp: user.msg("%s is already at full health." % target) return False - + min_healing = 20 max_healing = 40 - + # Retrieve healing range from kwargs, if present if "healing_range" in kwargs: min_healing = kwargs["healing_range"][0] @@ -1132,62 +1132,62 @@ def itemfunc_heal(item, user, target, **kwargs): if target.db.hp + to_heal > target.db.max_hp: to_heal = target.db.max_hp - target.db.hp # Cap healing to max HP target.db.hp += to_heal - + user.location.msg_contents("%s uses %s! %s regains %i HP!" % (user, item, target, to_heal)) - + def itemfunc_add_condition(item, user, target, **kwargs): """ Item function that gives the target one or more conditions. - + kwargs: conditions (list): Conditions added by the item formatted as a list of tuples: (condition (str), duration (int or True)) - + Notes: Should mostly be used for beneficial conditions - use itemfunc_attack for an item that can give an enemy a harmful condition. """ conditions = [("Regeneration", 5)] - - if not target: + + if not target: target = user # Target user if none specified - + if not target.attributes.has("max_hp"): # Is not a fighter user.msg("You can't use %s on that." % item) return False # Returning false aborts the item use - + # Retrieve condition / duration from kwargs, if present if "conditions" in kwargs: conditions = kwargs["conditions"] - + user.location.msg_contents("%s uses %s!" % (user, item)) - + # Add conditions to the target - for condition in conditions: + for condition in conditions: add_condition(target, user, condition[0], condition[1]) - + def itemfunc_cure_condition(item, user, target, **kwargs): """ Item function that'll remove given conditions from a target. - + kwargs: to_cure(list): List of conditions (str) that the item cures when used """ to_cure = ["Poisoned"] - - if not target: + + if not target: target = user # Target user if none specified - + if not target.attributes.has("max_hp"): # Is not a fighter user.msg("You can't use %s on that." % item) return False # Returning false aborts the item use - + # Retrieve condition(s) to cure from kwargs, if present if "to_cure" in kwargs: to_cure = kwargs["to_cure"] - + item_msg = "%s uses %s! " % (user, item) - + for key in target.db.conditions: if key in to_cure: # If condition specified in to_cure, remove it. @@ -1195,11 +1195,11 @@ def itemfunc_cure_condition(item, user, target, **kwargs): del target.db.conditions[key] user.location.msg_contents(item_msg) - + def itemfunc_attack(item, user, target, **kwargs): """ Item function that attacks a target. - + kwargs: min_damage(int): Minimum damage dealt by the attack max_damage(int): Maximum damage dealth by the attack @@ -1207,31 +1207,31 @@ def itemfunc_attack(item, user, target, **kwargs): inflict_condition(list): List of conditions inflicted on hit, formatted as a (str, int) tuple containing condition name and duration. - + Notes: Calls resolve_attack at the end. """ if not is_in_combat(user): user.msg("You can only use that in combat.") return False # Returning false aborts the item use - - if not target: + + if not target: user.msg("You have to specify a target to use %s! (use = )" % item) return False - + if target == user: user.msg("You can't attack yourself!") - return False - + return False + if not target.db.hp: # Has no HP user.msg("You can't use %s on that." % item) return False - + min_damage = 20 max_damage = 40 accuracy = 0 inflict_condition = [] - + # Retrieve values from kwargs, if present if "damage_range" in kwargs: min_damage = kwargs["damage_range"][0] @@ -1240,17 +1240,17 @@ def itemfunc_attack(item, user, target, **kwargs): accuracy = kwargs["accuracy"] if "inflict_condition" in kwargs: inflict_condition = kwargs["inflict_condition"] - + # Roll attack and damage attack_value = randint(1, 100) + accuracy damage_value = randint(min_damage, max_damage) - + # Account for "Accuracy Up" and "Accuracy Down" conditions if "Accuracy Up" in user.db.conditions: attack_value += 25 if "Accuracy Down" in user.db.conditions: attack_value -= 25 - + user.location.msg_contents("%s attacks %s with %s!" % (user, target, item)) resolve_attack(user, target, attack_value=attack_value, damage_value=damage_value, inflict_condition=inflict_condition) @@ -1283,14 +1283,14 @@ Only "item_func" is required, but item behavior can be further modified by specifying any of the following: item_uses (int): If defined, item has a limited number of uses - + item_selfonly (bool): If True, user can only use the item on themself - + item_consumable(True or str): If True, item is destroyed when it runs out of uses. If a string is given, the item will spawn a new object as it's destroyed, with the string specifying what prototype to spawn. - + item_kwargs (dict): Keyword arguments to pass to the function defined in item_func. Unique to each function, and can be used to make multiple items using the same function work differently. From 5175a61ed9c7fb3f18d912d6836f0ded91198cb1 Mon Sep 17 00:00:00 2001 From: FatherGrishnak <42367299+FatherGrishnak@users.noreply.github.com> Date: Tue, 14 Aug 2018 08:31:09 +0000 Subject: [PATCH 199/208] Update to fix #1644 --- evennia/objects/manager.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/evennia/objects/manager.py b/evennia/objects/manager.py index 4c81ea186a..6b4a16dde9 100644 --- a/evennia/objects/manager.py +++ b/evennia/objects/manager.py @@ -437,7 +437,7 @@ class ObjectDBManager(TypedObjectManager): """ Create and return a new object as a copy of the original object. All will be identical to the original except for the arguments given - specifically to this method. + specifically to this method. Object contents will not be copied. Args: original_object (Object): The object to make a copy from. @@ -502,6 +502,10 @@ class ObjectDBManager(TypedObjectManager): for script in original_object.scripts.all(): ScriptDB.objects.copy_script(script, new_obj=new_object) + # copy over all tags, if any + for tag in original_object.tags.get(): + new_object.tags.add(tag) + return new_object def clear_all_sessids(self): From da4f8f2358c50837250c5b02d9d8940995dd5c08 Mon Sep 17 00:00:00 2001 From: FatherGrishnak <42367299+FatherGrishnak@users.noreply.github.com> Date: Wed, 15 Aug 2018 00:55:13 +0000 Subject: [PATCH 200/208] Update manager.py --- evennia/objects/manager.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/evennia/objects/manager.py b/evennia/objects/manager.py index 6b4a16dde9..9907c960d6 100644 --- a/evennia/objects/manager.py +++ b/evennia/objects/manager.py @@ -503,9 +503,9 @@ class ObjectDBManager(TypedObjectManager): ScriptDB.objects.copy_script(script, new_obj=new_object) # copy over all tags, if any - for tag in original_object.tags.get(): - new_object.tags.add(tag) - + for tag in original_object.tags.get(return_tagobj=True, return_list=True): + new_object.tags.add(tag=tag.key, category=tag.category, data=tag.data) + return new_object def clear_all_sessids(self): From 2ca8f4ee54f6b8f2af8d364beb1b52066c32e7fd Mon Sep 17 00:00:00 2001 From: Griatch Date: Sat, 18 Aug 2018 10:38:06 +0200 Subject: [PATCH 201/208] Restart server, run collectstatic at init. Fix tintin++ default. Resolves #1593. --- evennia/server/amp_client.py | 4 +++- evennia/server/initial_setup.py | 21 ++++++++++++++++----- evennia/server/portal/ttype.py | 2 +- evennia/server/server.py | 5 ++--- evennia/server/sessionhandler.py | 3 +-- 5 files changed, 23 insertions(+), 12 deletions(-) diff --git a/evennia/server/amp_client.py b/evennia/server/amp_client.py index 8b9f9d4e8e..a4300adf4d 100644 --- a/evennia/server/amp_client.py +++ b/evennia/server/amp_client.py @@ -51,7 +51,7 @@ class AMPClientFactory(protocol.ReconnectingClientFactory): def buildProtocol(self, addr): """ - Creates an AMPProtocol instance when connecting to the server. + Creates an AMPProtocol instance when connecting to the AMP server. Args: addr (str): Connection address. Not used. @@ -108,6 +108,8 @@ class AMPServerClientProtocol(amp.AMPMultiConnectionProtocol): # back with the Server side. We also need the startup mode (reload, reset, shutdown) self.send_AdminServer2Portal( amp.DUMMYSESSION, operation=amp.PSYNC, spid=os.getpid(), info_dict=info_dict) + # run the intial setup if needed + self.factory.server.run_initial_setup() def data_to_portal(self, command, sessid, **kwargs): """ diff --git a/evennia/server/initial_setup.py b/evennia/server/initial_setup.py index 985a54dc95..9852229e45 100644 --- a/evennia/server/initial_setup.py +++ b/evennia/server/initial_setup.py @@ -59,7 +59,7 @@ def create_objects(): """ - logger.log_info("Creating objects (Account #1 and Limbo room) ...") + logger.log_info("Initial setup: Creating objects (Account #1 and Limbo room) ...") # Set the initial User's account object's username on the #1 object. # This object is pure django and only holds name, email and password. @@ -121,7 +121,7 @@ def create_channels(): Creates some sensible default channels. """ - logger.log_info("Creating default channels ...") + logger.log_info("Initial setup: Creating default channels ...") goduser = get_god_account() for channeldict in settings.DEFAULT_CHANNELS: @@ -144,11 +144,21 @@ def at_initial_setup(): mod = __import__(modname, fromlist=[None]) except (ImportError, ValueError): return - logger.log_info(" Running at_initial_setup() hook.") + logger.log_info("Initial setup: Running at_initial_setup() hook.") if mod.__dict__.get("at_initial_setup", None): mod.at_initial_setup() +def collectstatic(): + """ + Run collectstatic to make sure all web assets are loaded. + + """ + from django.core.management import call_command + logger.log_info("Initial setup: Gathering static resources using 'collectstatic'") + call_command('collectstatic', '--noinput') + + def reset_server(): """ We end the initialization by resetting the server. This makes sure @@ -159,8 +169,8 @@ def reset_server(): """ ServerConfig.objects.conf("server_epoch", time.time()) from evennia.server.sessionhandler import SESSIONS - logger.log_info(" Initial setup complete. Restarting Server once.") - SESSIONS.server.shutdown(mode='reset') + logger.log_info("Initial setup complete. Restarting Server once.") + SESSIONS.portal_reset_server() def handle_setup(last_step): @@ -186,6 +196,7 @@ def handle_setup(last_step): setup_queue = [create_objects, create_channels, at_initial_setup, + collectstatic, reset_server] # step through queue, from last completed function diff --git a/evennia/server/portal/ttype.py b/evennia/server/portal/ttype.py index d143b69747..faf4737842 100644 --- a/evennia/server/portal/ttype.py +++ b/evennia/server/portal/ttype.py @@ -116,7 +116,7 @@ class Ttype(object): self.protocol.protocol_flags["FORCEDENDLINE"] = False if cupper.startswith("TINTIN++"): - self.protocol.protocol_flags["FORCEDENDLINE"] = False + self.protocol.protocol_flags["FORCEDENDLINE"] = True if (cupper.startswith("XTERM") or cupper.endswith("-256COLOR") or diff --git a/evennia/server/server.py b/evennia/server/server.py index 1be9b5be0b..e42c85bae7 100644 --- a/evennia/server/server.py +++ b/evennia/server/server.py @@ -181,9 +181,6 @@ class Evennia(object): self.start_time = time.time() - # Run the initial setup if needed - self.run_initial_setup() - # initialize channelhandler channelhandler.CHANNELHANDLER.update() @@ -274,6 +271,8 @@ class Evennia(object): def run_initial_setup(self): """ + This is triggered by the amp protocol when the connection + to the portal has been established. This attempts to run the initial_setup script of the server. It returns if this is not the first time the server starts. Once finished the last_initial_setup_step is set to -1. diff --git a/evennia/server/sessionhandler.py b/evennia/server/sessionhandler.py index dcb9adb689..8e439b42dd 100644 --- a/evennia/server/sessionhandler.py +++ b/evennia/server/sessionhandler.py @@ -278,7 +278,7 @@ class ServerSessionHandler(SessionHandler): """ super(ServerSessionHandler, self).__init__(*args, **kwargs) - self.server = None + self.server = None # set at server initialization self.server_data = {"servername": _SERVERNAME} def _run_cmd_login(self, session): @@ -290,7 +290,6 @@ class ServerSessionHandler(SessionHandler): if not session.logged_in: self.data_in(session, text=[[CMD_LOGINSTART], {}]) - def portal_connect(self, portalsessiondata): """ Called by Portal when a new session has connected. From 5c3efc1e35f583e169ebf612427f173eab77c592 Mon Sep 17 00:00:00 2001 From: Griatch Date: Sun, 19 Aug 2018 21:40:19 +0200 Subject: [PATCH 202/208] Add interactive server-start mode. --- evennia/server/evennia_launcher.py | 47 +++++++++++++++++++++++++----- evennia/server/server.py | 9 +++--- 2 files changed, 44 insertions(+), 12 deletions(-) diff --git a/evennia/server/evennia_launcher.py b/evennia/server/evennia_launcher.py index 2f9206b4c1..c83d869336 100644 --- a/evennia/server/evennia_launcher.py +++ b/evennia/server/evennia_launcher.py @@ -467,9 +467,10 @@ ARG_OPTIONS = \ stop - shutdown server+portal reboot - shutdown server+portal, then start again reset - restart server in 'shutdown' mode - sstart - start only server (requires portal) + istart - start server in the foreground (until reload) + sstop - stop only server kill - send kill signal to portal+server (force) - skill = send kill signal only to server + skill - send kill signal only to server status - show server and portal run state info - show server and portal port info menu - show a menu of options @@ -955,14 +956,39 @@ def reboot_evennia(pprofiler=False, sprofiler=False): send_instruction(PSTATUS, None, _portal_running, _portal_not_running) -def stop_server_only(): +def start_server_interactive(): + """ + Start the Server under control of the launcher process (foreground) + + """ + def _iserver(): + _, server_twistd_cmd = _get_twistd_cmdline(False, False) + server_twistd_cmd.append("--nodaemon") + print("Starting Server in interactive mode (stop with Ctrl-C)...") + try: + Popen(server_twistd_cmd, env=getenv(), stderr=STDOUT).wait() + except KeyboardInterrupt: + print("... Stopped Server with Ctrl-C.") + else: + print("... Server stopped (leaving interactive mode).") + stop_server_only(when_stopped=_iserver) + + +def stop_server_only(when_stopped=None): """ Only stop the Server-component of Evennia (this is not useful except for debug) + Args: + when_stopped (callable): This will be called with no arguments when Server has stopped (or + if it had already stopped when this is called). + """ def _server_stopped(*args): - print("... Server stopped.") - _reactor_stop() + if when_stopped: + when_stopped() + else: + print("... Server stopped.") + _reactor_stop() def _portal_running(response): _, srun, _, _, _, _ = _parse_status(response) @@ -971,8 +997,11 @@ def stop_server_only(): wait_for_status_reply(_server_stopped) send_instruction(SSHUTD, {}) else: - print("Server is not running.") - _reactor_stop() + if when_stopped: + when_stopped() + else: + print("Server is not running.") + _reactor_stop() def _portal_not_running(fail): print("Evennia is not running.") @@ -1937,7 +1966,7 @@ def main(): # launch menu for operation init_game_directory(CURRENT_DIR, check_db=True) run_menu() - elif option in ('status', 'info', 'start', 'reload', 'reboot', + elif option in ('status', 'info', 'start', 'istart', 'reload', 'reboot', 'reset', 'stop', 'sstop', 'kill', 'skill'): # operate the server directly if not SERVER_LOGFILE: @@ -1948,6 +1977,8 @@ def main(): query_info() elif option == "start": start_evennia(args.profiler, args.profiler) + elif option == "istart": + start_server_interactive() elif option == 'reload': reload_evennia(args.profiler) elif option == 'reboot': diff --git a/evennia/server/server.py b/evennia/server/server.py index e42c85bae7..5a225704c2 100644 --- a/evennia/server/server.py +++ b/evennia/server/server.py @@ -507,10 +507,11 @@ ServerConfig.objects.conf("server_starting_mode", True) # what to execute from. application = service.Application('Evennia') -# custom logging -logfile = logger.WeeklyLogFile(os.path.basename(settings.SERVER_LOG_FILE), - os.path.dirname(settings.SERVER_LOG_FILE)) -application.setComponent(ILogObserver, logger.ServerLogObserver(logfile).emit) +if "--nodaemon" not in sys.argv: + # custom logging, but only if we are not running in interactive mode + logfile = logger.WeeklyLogFile(os.path.basename(settings.SERVER_LOG_FILE), + os.path.dirname(settings.SERVER_LOG_FILE)) + application.setComponent(ILogObserver, logger.ServerLogObserver(logfile).emit) # The main evennia server program. This sets up the database # and is where we store all the other services. From 5a3bdfacca4916a53505c66b4feed29aac61ea31 Mon Sep 17 00:00:00 2001 From: Griatch Date: Mon, 20 Aug 2018 20:21:22 +0200 Subject: [PATCH 203/208] Fix except-finally section that swallowed command unittest error message. Resolves #1629. --- CHANGELOG.md | 7 +++++ evennia/commands/default/general.py | 2 +- evennia/commands/default/tests.py | 44 ++++++++++++++--------------- 3 files changed, 30 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d876b419d7..b0cc6ac95e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,9 @@ - Made Portal the AMP Server for starting/restarting the Server (the AMP client) - Dynamic logging now happens using `evennia -l` rather than by interactive. - Made AMP secure against erroneous HTTP requests on the wrong port (return error messages). +- The `evennia istart` option will start/switch the Server in foreground (interactive) mode, where it logs + to terminal and can be stopped with Ctrl-C. Using `evennia reload`, or reloading in-game, will + return Server to normal daemon operation. ### Prototype changes @@ -26,9 +29,13 @@ change from Evennia 0.7 which allowed 'mixin' prototypes without `typeclass`/`prototype_key`. To make a mixin now, give it a default typeclass, like `evennia.objects.objects.DefaultObject` and just override in the child as needed. +- Spawning an object using a prototype will automatically assign a new tag to it, named the same as + the `prototype_key` and with the category `from_prototype`. - The spawn command was extended to accept a full prototype on one line. - The spawn command got the /save switch to save the defined prototype and its key. - The command spawn/menu will now start an OLC (OnLine Creation) menu to load/save/edit/spawn prototypes. +- The OLC allows for updating all objects previously created using a given prototype with any + changes done. ### EvMenu diff --git a/evennia/commands/default/general.py b/evennia/commands/default/general.py index d72a2006b9..85fb1b4dd4 100644 --- a/evennia/commands/default/general.py +++ b/evennia/commands/default/general.py @@ -71,7 +71,7 @@ class CmdLook(COMMAND_DEFAULT_CLASS): target = caller.search(self.args) if not target: return - self.msg((caller.at_look(target), {'type':'look'}), options=None) + self.msg((caller.at_look(target), {'type': 'look'}), options=None) class CmdNick(COMMAND_DEFAULT_CLASS): diff --git a/evennia/commands/default/tests.py b/evennia/commands/default/tests.py index 3fb762910d..19277c168a 100644 --- a/evennia/commands/default/tests.py +++ b/evennia/commands/default/tests.py @@ -105,28 +105,28 @@ class CommandTest(EvenniaTest): pass except InterruptCommand: pass - finally: - # clean out evtable sugar. We only operate on text-type - stored_msg = [args[0] if args and args[0] else kwargs.get("text", utils.to_str(kwargs, force_string=True)) - for name, args, kwargs in receiver.msg.mock_calls] - # Get the first element of a tuple if msg received a tuple instead of a string - stored_msg = [smsg[0] if isinstance(smsg, tuple) else smsg for smsg in stored_msg] - if msg is not None: - # set our separator for returned messages based on parsing ansi or not - msg_sep = "|" if noansi else "||" - # Have to strip ansi for each returned message for the regex to handle it correctly - returned_msg = msg_sep.join(_RE.sub("", ansi.parse_ansi(mess, strip_ansi=noansi)) - for mess in stored_msg).strip() - if msg == "" and returned_msg or not returned_msg.startswith(msg.strip()): - sep1 = "\n" + "=" * 30 + "Wanted message" + "=" * 34 + "\n" - sep2 = "\n" + "=" * 30 + "Returned message" + "=" * 32 + "\n" - sep3 = "\n" + "=" * 78 - retval = sep1 + msg.strip() + sep2 + returned_msg + sep3 - raise AssertionError(retval) - else: - returned_msg = "\n".join(str(msg) for msg in stored_msg) - returned_msg = ansi.parse_ansi(returned_msg, strip_ansi=noansi).strip() - receiver.msg = old_msg + + # clean out evtable sugar. We only operate on text-type + stored_msg = [args[0] if args and args[0] else kwargs.get("text", utils.to_str(kwargs, force_string=True)) + for name, args, kwargs in receiver.msg.mock_calls] + # Get the first element of a tuple if msg received a tuple instead of a string + stored_msg = [smsg[0] if isinstance(smsg, tuple) else smsg for smsg in stored_msg] + if msg is not None: + # set our separator for returned messages based on parsing ansi or not + msg_sep = "|" if noansi else "||" + # Have to strip ansi for each returned message for the regex to handle it correctly + returned_msg = msg_sep.join(_RE.sub("", ansi.parse_ansi(mess, strip_ansi=noansi)) + for mess in stored_msg).strip() + if msg == "" and returned_msg or not returned_msg.startswith(msg.strip()): + sep1 = "\n" + "=" * 30 + "Wanted message" + "=" * 34 + "\n" + sep2 = "\n" + "=" * 30 + "Returned message" + "=" * 32 + "\n" + sep3 = "\n" + "=" * 78 + retval = sep1 + msg.strip() + sep2 + returned_msg + sep3 + raise AssertionError(retval) + else: + returned_msg = "\n".join(str(msg) for msg in stored_msg) + returned_msg = ansi.parse_ansi(returned_msg, strip_ansi=noansi).strip() + receiver.msg = old_msg return returned_msg From 4c3ad728524b98506c2c86aa829e35cee226c866 Mon Sep 17 00:00:00 2001 From: Griatch Date: Mon, 20 Aug 2018 20:39:04 +0200 Subject: [PATCH 204/208] Improve docstring --- evennia/utils/utils.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/evennia/utils/utils.py b/evennia/utils/utils.py index cd6c57a21f..c7c6a03d06 100644 --- a/evennia/utils/utils.py +++ b/evennia/utils/utils.py @@ -1010,17 +1010,17 @@ def delay(timedelay, callback, *args, **kwargs): Delay the return of a value. Args: - timedelay (int or float): The delay in seconds - callback (callable): Will be called with optional - arguments after `timedelay` seconds. - args (any, optional): Will be used as arguments to callback + timedelay (int or float): The delay in seconds + callback (callable): Will be called as `callback(*args, **kwargs)` + after `timedelay` seconds. + args (any, optional): Will be used as arguments to callback Kwargs: - persistent (bool, optional): should make the delay persistent - over a reboot or reload - any (any): Will be used to call the callback. + persistent (bool, optional): should make the delay persistent + over a reboot or reload + any (any): Will be used as keyword arguments to callback. Returns: - deferred (deferred): Will fire fire with callback after + deferred (deferred): Will fire with callback after `timedelay` seconds. Note that if `timedelay()` is used in the commandhandler callback chain, the callback chain can be defined directly in the command body and don't need to be From 3b2da6602840fe0a030654b01a17f0fa0111bdc6 Mon Sep 17 00:00:00 2001 From: Griatch Date: Thu, 23 Aug 2018 18:28:28 +0200 Subject: [PATCH 205/208] Support inflection of colored object-names. Resolves #1572. --- evennia/objects/objects.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/evennia/objects/objects.py b/evennia/objects/objects.py index d42f20c9ae..27d8147999 100644 --- a/evennia/objects/objects.py +++ b/evennia/objects/objects.py @@ -23,6 +23,7 @@ from evennia.commands.cmdsethandler import CmdSetHandler from evennia.commands import cmdhandler from evennia.utils import search from evennia.utils import logger +from evennia.utils import ansi from evennia.utils.utils import (variable_from_module, lazy_property, make_iter, to_unicode, is_iter, list_to_string, to_str) @@ -305,12 +306,13 @@ class DefaultObject(with_metaclass(TypeclassBase, ObjectDB)): count (int): Number of objects of this type looker (Object): Onlooker. Not used by default. Kwargs: - key (str): Optional key to pluralize, use this instead of the object's key. + key (str): Optional key to pluralize, if given, use this instead of the object's key. Returns: singular (str): The singular form to display. plural (str): The determined plural form of the key, including the count. """ key = kwargs.get("key", self.key) + key = ansi.ANSIString(key) # this is needed to allow inflection of colored names plural = _INFLECT.plural(key, 2) plural = "%s %s" % (_INFLECT.number_to_words(count, threshold=12), plural) singular = _INFLECT.an(key) From 6edd3fc6bf492bfb7c85d86ebbe2f5195316e85b Mon Sep 17 00:00:00 2001 From: Griatch Date: Thu, 23 Aug 2018 20:46:13 +0200 Subject: [PATCH 206/208] Add menudebug command for debugging EvMenu --- CHANGELOG.md | 7 ++++++ evennia/prototypes/menus.py | 3 ++- evennia/utils/evmenu.py | 47 +++++++++++++++++++++++++++++++++---- 3 files changed, 52 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b0cc6ac95e..22e14ae163 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,8 +45,14 @@ current node instead of failing. - Better error handling of in-node syntax errors. - Improve dedent of default text/helptext formatter. Right-strip whitespace. +- Add `debug` option when creating menu - this turns of persistence and makes the `menudebug` + command available for examining the current menu state. +### Webclient + +- Refactoring of webclient structure. + ### Utils - Added new `columnize` function for easily splitting text into multiple columns. At this point it @@ -60,6 +66,7 @@ ### General - Start structuring the `CHANGELOG` to list features in more detail. +- Inflection and grouping of multiple objects in default room (an box, three boxes) ### Contribs diff --git a/evennia/prototypes/menus.py b/evennia/prototypes/menus.py index 0e4f59ffbc..cc5a9f5914 100644 --- a/evennia/prototypes/menus.py +++ b/evennia/prototypes/menus.py @@ -2396,4 +2396,5 @@ def start_olc(caller, session=None, prototype=None): "node_prototype_save": node_prototype_save, "node_prototype_spawn": node_prototype_spawn } - OLCMenu(caller, menudata, startnode='node_index', session=session, olc_prototype=prototype) + OLCMenu(caller, menudata, startnode='node_index', session=session, + olc_prototype=prototype, debug=True) diff --git a/evennia/utils/evmenu.py b/evennia/utils/evmenu.py index 0297170da2..127e2d3f13 100644 --- a/evennia/utils/evmenu.py +++ b/evennia/utils/evmenu.py @@ -165,6 +165,7 @@ evennia.utils.evmenu`. """ from __future__ import print_function import random +import inspect from builtins import object, range from inspect import isfunction, getargspec @@ -173,7 +174,7 @@ from evennia import Command, CmdSet from evennia.utils import logger from evennia.utils.evtable import EvTable from evennia.utils.ansi import strip_ansi -from evennia.utils.utils import mod_import, make_iter, pad, m_len, is_iter, dedent +from evennia.utils.utils import mod_import, make_iter, pad, to_str, m_len, is_iter, dedent, crop from evennia.commands import cmdhandler # read from protocol NAWS later? @@ -322,7 +323,7 @@ class EvMenu(object): auto_quit=True, auto_look=True, auto_help=True, cmd_on_exit="look", persistent=False, startnode_input="", session=None, - **kwargs): + debug=False, **kwargs): """ Initialize the menu tree and start the caller onto the first node. @@ -375,7 +376,8 @@ class EvMenu(object): *pickle*. When the server is reloaded, the latest node shown will be completely re-run with the same input arguments - so be careful if you are counting up some persistent counter or similar - the counter may be run twice if - reload happens on the node that does that. + reload happens on the node that does that. Note that if `debug` is True, + this setting is ignored and assumed to be False. startnode_input (str or (str, dict), optional): Send an input text to `startnode` as if a user input text from a fictional previous node. If including the dict, this will be passed as **kwargs to that node. When the server reloads, @@ -385,6 +387,10 @@ class EvMenu(object): for the very first display of the first node - after that, EvMenu itself will keep the session updated from the command input. So a persistent menu will *not* be using this same session anymore after a reload. + debug (bool, optional): If set, the 'menudebug' command will be made available + by default in all nodes of the menu. This will print out the current state of + the menu. Deactivate for production use! When the debug flag is active, the + `persistent` flag is deactivated. Kwargs: any (any): All kwargs will become initialization variables on `caller.ndb._menutree`, @@ -408,7 +414,7 @@ class EvMenu(object): """ self._startnode = startnode self._menutree = self._parse_menudata(menudata) - self._persistent = persistent + self._persistent = persistent if not debug else False self._quitting = False if startnode not in self._menutree: @@ -422,6 +428,7 @@ class EvMenu(object): self.auto_quit = auto_quit self.auto_look = auto_look self.auto_help = auto_help + self.debug_mode = debug self._session = session if isinstance(cmd_on_exit, str): # At this point menu._session will have been replaced by the @@ -844,6 +851,36 @@ class EvMenu(object): if self.cmd_on_exit is not None: self.cmd_on_exit(self.caller, self) + def print_debug_info(self, arg): + """ + Messages the caller with the current menu state, for debug purposes. + + Args: + arg (str): Arg to debug instruction, either nothing, 'full' or the name + of a property to inspect. + + """ + all_props = inspect.getmembers(self) + all_methods = [name for name, _ in inspect.getmembers(self, predicate=inspect.ismethod)] + all_builtins = [name for name, _ in inspect.getmembers(self, predicate=inspect.isbuiltin)] + props = {prop: value for prop, value in all_props if prop not in all_methods and + prop not in all_builtins and not prop.endswith("__")} + + if arg: + if arg in props: + debugtxt = " |y* {}:|n\n{}".format(arg, props[arg]) + elif arg == 'full': + debugtxt = ("|yMENU DEBUG full ... |n\n" + "\n".join(props) + + "\n |y... END MENU DEBUG|n") + else: + debugtxt = "|yUsage: menudebug full||n" + else: + debugtxt = "|yMENU DEBUG properties:|n\n" + "\n".join("|y *|n {}: {}".format( + prop, crop(to_str(value, force_string=True), width=50)) + for prop, value in sorted(props.items())) + debugtxt += "\n|y... END MENU DEBUG (use menudebug for full value)|n" + self.caller.msg(debugtxt) + def parse_input(self, raw_string): """ Parses the incoming string from the menu user. @@ -870,6 +907,8 @@ class EvMenu(object): self.display_helptext() elif self.auto_quit and cmd in ("quit", "q", "exit"): self.close_menu() + elif self.debug_mode and cmd.startswith("menudebug"): + self.print_debug_info(cmd[9:].strip()) elif self.default: goto, goto_kwargs, execfunc, exec_kwargs = self.default self.run_exec_then_goto(execfunc, goto, raw_string, exec_kwargs, goto_kwargs) From a51b711831353e8b03cb9a8a3690c34c7b1fa849 Mon Sep 17 00:00:00 2001 From: Griatch Date: Thu, 23 Aug 2018 22:09:36 +0200 Subject: [PATCH 207/208] Resolve error in prototype validate menu node --- evennia/prototypes/menus.py | 2 +- evennia/prototypes/spawner.py | 37 +++++++++++++++++++++++------------ 2 files changed, 25 insertions(+), 14 deletions(-) diff --git a/evennia/prototypes/menus.py b/evennia/prototypes/menus.py index cc5a9f5914..1d291e8732 100644 --- a/evennia/prototypes/menus.py +++ b/evennia/prototypes/menus.py @@ -377,7 +377,7 @@ def _default_parse(raw_inp, choices, *args): def node_validate_prototype(caller, raw_string, **kwargs): """General node to view and validate a protototype""" - prototype = _get_flat_menu_prototype(caller, validate=False) + prototype = _get_flat_menu_prototype(caller, refresh=True, validate=False) prev_node = kwargs.get("back", "index") _, text = _validate_prototype(prototype) diff --git a/evennia/prototypes/spawner.py b/evennia/prototypes/spawner.py index f250287c8f..5ead6239e7 100644 --- a/evennia/prototypes/spawner.py +++ b/evennia/prototypes/spawner.py @@ -143,25 +143,34 @@ _NON_CREATE_KWARGS = _CREATE_OBJECT_KWARGS + _PROTOTYPE_META_NAMES # Helper -def _get_prototype(dic, prot, protparents): +def _get_prototype(inprot, protparents, uninherited=None, _workprot=None): """ Recursively traverse a prototype dictionary, including multiple inheritance. Use validate_prototype before this, we don't check for infinite recursion here. + Args: + inprot (dict): Prototype dict (the individual prototype, with no inheritance included). + protparents (dict): Available protparents, keyed by prototype_key. + uninherited (dict): Parts of prototype to not inherit. + _workprot (dict, optional): Work dict for the recursive algorithm. + """ - # we don't overload the prototype_key - prototype_key = prot.get('prototype_key', None) - if "prototype_parent" in dic: + _workprot = {} if _workprot is None else _workprot + if "prototype_parent" in inprot: # move backwards through the inheritance - for prototype in make_iter(dic["prototype_parent"]): + for prototype in make_iter(inprot["prototype_parent"]): # Build the prot dictionary in reverse order, overloading - new_prot = _get_prototype(protparents.get(prototype.lower(), {}), prot, protparents) - prot.update(new_prot) - prot.update(dic) - prot['prototype_key'] = prototype_key - prot.pop("prototype_parent", None) # we don't need this anymore - return prot + new_prot = _get_prototype(protparents.get(prototype.lower(), {}), + protparents, _workprot=_workprot) + _workprot.update(new_prot) + # the inprot represents a higher level (a child prot), which should override parents + _workprot.update(inprot) + if uninherited: + # put back the parts that should not be inherited + _workprot.update(uninherited) + _workprot.pop("prototype_parent", None) # we don't need this for spawning + return _workprot def flatten_prototype(prototype, validate=False): @@ -181,7 +190,8 @@ def flatten_prototype(prototype, validate=False): protparents = {prot['prototype_key'].lower(): prot for prot in protlib.search_prototype()} protlib.validate_prototype(prototype, None, protparents, is_prototype_base=validate, strict=validate) - return _get_prototype(prototype, {}, protparents) + return _get_prototype(prototype, protparents, + uninherited={"prototype_key": prototype.get("prototype_key")}) return {} @@ -519,7 +529,8 @@ def spawn(*prototypes, **kwargs): for prototype in prototypes: protlib.validate_prototype(prototype, None, protparents, is_prototype_base=True) - prot = _get_prototype(prototype, {}, protparents) + prot = _get_prototype(prototype, protparents, + uninherited={"prototype_key": prototype.get("prototype_key")}) if not prot: continue From f1f70730fb204e8043f2be83376f7e1fa39494f8 Mon Sep 17 00:00:00 2001 From: Griatch Date: Sat, 25 Aug 2018 23:46:05 +0200 Subject: [PATCH 208/208] Correct olc update options, add local display to menudebug command --- evennia/prototypes/menus.py | 16 ++++++++-------- evennia/utils/evmenu.py | 25 ++++++++++++++++++++----- 2 files changed, 28 insertions(+), 13 deletions(-) diff --git a/evennia/prototypes/menus.py b/evennia/prototypes/menus.py index 1d291e8732..edef289962 100644 --- a/evennia/prototypes/menus.py +++ b/evennia/prototypes/menus.py @@ -2027,14 +2027,14 @@ def node_apply_diff(caller, **kwargs): sep=" |r->|n ", new='', change=inst)) options.append(_keep_option(key, prototype, base_obj, obj_prototype, diff, update_objects, back_node)) - options.extend( - [{"key": ("|wu|Wpdate {} objects".format(len(update_objects)), "update", "u"), - "desc": "Update {} objects".format(len(update_objects)), - "goto": (_apply_diff, {"prototype": prototype, "objects": update_objects, - "back_node": back_node, "diff": diff, "base_obj": base_obj})}, - {"key": ("|wr|Weset changes", "reset", "r"), - "goto": ("node_apply_diff", {"prototype": prototype, "back_node": back_node, - "objects": update_objects})}]) + options.extend( + [{"key": ("|wu|Wpdate {} objects".format(len(update_objects)), "update", "u"), + "desc": "Update {} objects".format(len(update_objects)), + "goto": (_apply_diff, {"prototype": prototype, "objects": update_objects, + "back_node": back_node, "diff": diff, "base_obj": base_obj})}, + {"key": ("|wr|Weset changes", "reset", "r"), + "goto": ("node_apply_diff", {"prototype": prototype, "back_node": back_node, + "objects": update_objects})}]) if ichanges < 1: text = ["Analyzed a random sample object (out of {}) - " diff --git a/evennia/utils/evmenu.py b/evennia/utils/evmenu.py index 127e2d3f13..f82dc5cb1f 100644 --- a/evennia/utils/evmenu.py +++ b/evennia/utils/evmenu.py @@ -866,19 +866,34 @@ class EvMenu(object): props = {prop: value for prop, value in all_props if prop not in all_methods and prop not in all_builtins and not prop.endswith("__")} + local = {key: var for key, var in locals().items() + if key not in all_props and not key.endswith("__")} + if arg: if arg in props: debugtxt = " |y* {}:|n\n{}".format(arg, props[arg]) + elif arg in local: + debugtxt = " |y* {}:|n\n{}".format(arg, local[arg]) elif arg == 'full': - debugtxt = ("|yMENU DEBUG full ... |n\n" + "\n".join(props) + + debugtxt = ("|yMENU DEBUG full ... |n\n" + "\n".join( + "|y *|n {}: {}".format(key, val) + for key, val in sorted(props.items())) + + "\n |yLOCAL VARS:|n\n" + "\n".join( + "|y *|n {}: {}".format(key, val) + for key, val in sorted(local.items())) + "\n |y... END MENU DEBUG|n") else: debugtxt = "|yUsage: menudebug full||n" else: - debugtxt = "|yMENU DEBUG properties:|n\n" + "\n".join("|y *|n {}: {}".format( - prop, crop(to_str(value, force_string=True), width=50)) - for prop, value in sorted(props.items())) - debugtxt += "\n|y... END MENU DEBUG (use menudebug for full value)|n" + debugtxt = ("|yMENU DEBUG properties ... |n\n" + "\n".join( + "|y *|n {}: {}".format( + key, crop(to_str(val, force_string=True), width=50)) + for key, val in sorted(props.items())) + + "\n |yLOCAL VARS:|n\n" + "\n".join( + "|y *|n {}: {}".format( + key, crop(to_str(val, force_string=True), width=50)) + for key, val in sorted(local.items())) + + "\n |y... END MENU DEBUG|n") self.caller.msg(debugtxt) def parse_input(self, raw_string):