Altering @help to display live values



  • I'm considering the value and feasibility of making an extension to the help code Evennia has, for viewing the code associated with a command.

    This is conceptual. I'm learning Python code because it is valuable both to me as a member to the community and to my day job, seeing if this is worth pursuing.

    The Theory
    Help files are typically static, but the code for a command may change over time. By pulling some or all of the values in the code into a help file's output, you might make that help file more evergreen and therefore more accurate and useful.

    The Problem
    Best way to do this? Do we signpost entire code blocks and offer a secondary @help command, or do we selectively insert details into the helpfiles?
    Will this alteration around a command's code slow it down in a meaningful way?

    Reference Code
    I'm going to use the Donate command from the Arx github as my learning example. The raw code below is a reference to the public Git, and only posted here for learning purposes. It helps me to point at the changes that might be made in a specific example, instead of theoretical changes.

    Edit: tried to spoiler the code so it would take less space and failed. Anyone know the syntax to put code in a spoiler?

    class CmdDonate(ArxCommand):
        """
        Donates money to some group
    
        Usage:
            +donate <group name>=<amount>
            +donate/hype <player>,<group>=<amount>
            +donate/score [<group>]
    
        Donates money to some group of npcs in exchange for prestige.
        +donate/score lists donation amounts. Costs 1 AP.
        """
        key = "+donate"
        locks = "cmd:all()"
        help_category = "Social"
        action_point_cost = 1
    
        @property
        def donations(self):
            """Queryset of donations by caller"""
            return self.caller.player.Dominion.assets.donations.all().order_by('amount')
    
        def func(self):
            """Execute command."""
            caller = self.caller
            try:
                if "score" in self.switches:
                    return self.display_score()
                if not self.lhs:
                    self.list_donations(caller)
                    return
                group = self.get_donation_target()
                if not group:
                    return
                try:
                    val = int(self.rhs)
                    if val > caller.db.currency:
                        raise CommandError("Not enough money.")
                    if val <= 0:
                        raise ValueError
                    if not caller.player.pay_action_points(self.action_point_cost):
                        raise CommandError("Not enough AP.")
                    caller.pay_money(val)
                    group.donate(val, self.caller)
                except (TypeError, ValueError):
                    raise CommandError("Must give a positive number.")
            except CommandError as err:
                caller.msg(err)
    
        def list_donations(self, caller):
            """Lists donations to the caller"""
            msg = "{wDonations:{n\n"
            table = PrettyTable(["{wGroup{n", "{wTotal{n"])
            for donation in self.donations:
                table.add_row([str(donation.receiver), donation.amount])
            msg += str(table)
            caller.msg(msg)
    
        def get_donation_target(self):
            """Get donation object"""
            org, npc = self.get_org_or_npc_from_args()
            if not org and not npc:
                return
            if "hype" in self.switches:
                player = self.caller.player.search(self.lhslist[0])
                if not player:
                    return
                donations = player.Dominion.assets.donations
            else:
                donations = self.caller.player.Dominion.assets.donations
            if org:
                return donations.get_or_create(organization=org)[0]
            return donations.get_or_create(npc_group=npc)[0]
    
        def get_org_or_npc_from_args(self):
            """Get a tuple of org, npc used for getting the donation object"""
            org, npc = None, None
            if "hype" in self.switches:
                if len(self.lhslist) < 2:
                    raise CommandError("Usage: <player>,<group>=<amount>")
                name = self.lhslist[1]
            else:
                name = self.lhs
            try:
                org = Organization.objects.get(name__iexact=name)
                if org.secret and not self.caller.check_permstring("builders"):
                    if not org.active_members.filter(player__player=self.caller.player):
                        org = None
                        raise Organization.DoesNotExist
            except Organization.DoesNotExist:
                try:
                    npc = InfluenceCategory.objects.get(name__iexact=name)
                except InfluenceCategory.DoesNotExist:
                    raise CommandError("Could not find an organization or npc group by the name %s." % name)
            return org, npc
    
        def display_score(self):
            """Displays score for donations"""
            if self.args:
                return self.display_score_for_group()
            return self.display_top_donor_for_each_group()
    
        def display_score_for_group(self):
            """Displays a list of the top 10 donors for a given group"""
            org, npc = self.get_org_or_npc_from_args()
            if org and org.secret:
                raise CommandError("Cannot display donations for secret orgs.")
            group = org or npc
            if not group:
                return
            msg = "Top donors for %s\n" % group
            table = PrettyTable(["Donor", "Amount"])
            for donation in group.donations.filter(amount__gt=0).distinct().order_by('-amount'):
                table.add_row([str(donation.giver), str(donation.amount)])
            msg += str(table)
            self.msg(msg)
    
        def display_top_donor_for_each_group(self):
            """Displays the highest donor for each group"""
            orgs = Organization.objects.filter(donations__isnull=False)
            if not self.caller.check_permstring("builders"):
                orgs = orgs.exclude(secret=True)
            orgs = list(orgs.distinct())
            npcs = list(InfluenceCategory.objects.filter(donations__isnull=False).distinct())
            groups = orgs + npcs
            table = PrettyTable(["Group", "Top Donor", "Donor's Total Donations"])
            top_donations = []
            for group in groups:
                donation = group.donations.filter(amount__gt=0).order_by('-amount').distinct().first()
                if donation:
                    top_donations.append(donation)
            top_donations.sort(key=lambda x: x.amount, reverse=True)
            for donation in top_donations:
                table.add_row([str(donation.receiver), str(donation.giver), str(donation.amount)])
            self.msg(str(table))
    

    Method One - Full Code Block
    Acknowledging this might be possible, but not recommending it.

    This would just be a full verbatim output of a class file in game, such as the CmdDonate class above.

    Bulky and if they want that much detail, why not just go to the github?

    Method Two - Selective Inserts
    Help files are mostly static and remain functionally similar, but numerical values and examples of rolled stats are pulled from named fields in the associated class.

    Advantage: Basic tweaks automatically show up, users won't be confused when a value is altered and the helpfile gets missed. Helpfiles are Evergreen, unless there is a large change.

    Drawback: Changes the structure of the class. Helpfile goes at the bottom as it has to explicitly pull in values already defined. Will possibly impact command efficiency.

    Using the Example: The AP cost for Donate would be automatically taken from action_point_cost and integrated into the help text.

    Markup Style (Optional): To ensure users know which values are static and which are pulled from the code, code values might be given a different default color than the standard @help output. This could be a Green or White highlight, for example.



  • Reviewed the xp.py code after realizing there was a live reference already in the 'help train' file.

    In game output looks like this and runs in the file.

    You can train 5 people per week.
    Your current cost to train another character is 0 AP.
    

    Code that generates it is after the main helpfile but before the helpfile footer.

        def get_help(self, caller, cmdset):
            if caller.char_ob:
                caller = caller.char_ob
            trained = ", ".join(ob.key for ob in self.currently_training(caller))
            if trained:
                trained = "You have trained %s this week. " % trained
            msg = self.__doc__ + """
    
        You can train {w%s{n people per week.
        %sYour current cost to train another character is {w%s{n AP.
        """ % (self.max_trainees(caller), trained, self.action_point_cost(caller))
            return msg
    

    Taking this a step further, it seems possible to put the entirety of the help text into def get_help, and since it pulls from code further down, it doesn't look like order is important as I thought it was.

    Yes, this is me teaching myself how this all works. Nobody else was going to do it.

    Tracked down another example of the def get_help in the overrides.py file, where it describes itself as a tool for custom helpfiles and permissions. Cool, so that is how they did custom permission locks on the theology/occult files.

    Nothing I was thinking about doing is actually new, I just need to understand how to properly insert this stuff.

    So! Back to the example I started with, the donate command. Code might look like this.

        def get_help(self, caller, cmdset):
            msg = self.__doc__ + """
    
       +donate/score lists donation amounts. Costs {w%s{n AP.
    
        """ % (action_point_cost)
            return msg
    
    

    We would remove the related string from the main helpfile and insert this def into the class CmdDonate(ArxCommand) to get a reference to the action_point_cost string baked into the helpfile.

    Hopefully I got that all right.



  • You can look in the Clue command for how Tehom did it for another command:
    https://github.com/Arx-Game/arxcode/blob/6c60b99a0843f4f27ad6dd102afa1bc55846d6bb/web/character/investigation.py#L1339

        def get_help(self, caller, cmdset):
            """Custom helpfile that lists clue sharing costs"""
            caller = caller.player_ob
            doc = self.__doc__
            doc += "\n\nYour cost of sharing clues is %s." % caller.clue_cost
            return doc
    

    I think your example will get a syntax error since there's no action_point_cost defined inside that function.



  • @Groth action_point_cost is defined, same as caller.clue_cost is in the clue example, but I did fail to define caller = caller.player_ob. I didn't realize it had to have that, I'll fix it.



  • @Selerik
    It's because the way Arx is set up, it distinguishes between Account and Character and the caller for commands is the Account. There's also some confusing subclasses that I no longer remember how they're laid out because I havn't touched that code in 6 months.



  • Why not just store the instruction string as a parameter in the command's code and then have the help command query the code for the parameter and print it out?

    Edit. Derp. I read back. Looks like that was covered.



  • Screwed this up with the below attempt. The internal reference needed self.action_point_cost, @Groth was right and it wasn't defined the right way. I bow to the wisdom I denied.


Log in to reply