0

I am implementing a decision-making pattern for enemies in my turn-based game. In order to help understand the code (I hope!), I'll explain the pattern it's implementing:

  • When an enemy is instructed to take_a_turn(), they make a plan to take every action available to them on every valid target (e.g. attack player, heal self, heal ally) in the fight.
  • Each plan consists of an action (function) and a score (int).
  • Enemies have goals with numeric values that influence the score of the plans they make. For example, one enemy may only have the goal of hurting players and so will never use a healing ability. Another enemy may have both goals, but favor hurting players more than healing.
  • When plans are made, scores are weighted by various things. For example, an enemy may favor hurting players more than healing allies based on its goals, but if a specific ally's health is critically low, the value of healing that ally may be high enough that the plan is favored temporarily above smacking players.
  • When a viable plan is discovered, the function (e.g. the attack or heal spell) and its arguments (the enemy using it, the target, the state of the fight) are assigned to the plan action variable to be invoked later, if the plan is selected.
  • Once the enemy has made every viable plan, they are sorted by score and the highest score plan is chosen. This is the "best" thing the enemy could do that turn based on their abilities, goals, and the current state of the fight. The chosen plan's action variable is then executed, causing the enemy to do the thing.

This system works well. I ported it successfully from a previous game I wrote in C#. When the function is assigned to the plan's action, in C#, a lambda is used to early-bind the arguments (user, target, fight state).

Everything in Python works correctly... except it executes the wrong action (it appears to execute the last-planned action), even though it selects the correct plan (the 2nd of 3, in my test case) and the selected plan debug text printed out is correct. I believe this may be related to python scope and binding differences from C#, which I have researched extensively and tried several different solutions (lambda, inline function, partial, different argument constructions) but all behave identically. Unfortunately, the plan action variable, which is assigned a function, doesn't visualize in my IDE's (pycharm) debugger, so I can't explicitly see it change while stepping through execution. Here's the relevant code. I've omitted references that aren't material to keep it shorter, but if any references I skip are possibly useful, comment and I'll add them.

Enemy, with all their attendant decision-making logic:

class GoalType(Enum):
    damage_player = 1
    debuff_player = 2 #not used in this example
    heal_ally = 3
    buff_ally = 4 # not used in this example
    summon = 5 # not used in this example


class Goal:
    def __init__(self, goal_type: GoalType, value: int):
        self.goal_type = goal_type
        self.value = value

    # this method looks like overkill, but several future goals have multiple contributor types
    @staticmethod
    def get_contributor_effects_by_goal_type(goal_type: GoalType):
        if goal_type == GoalType.damage_player:
            contribs = [EffectType.damage_health]
        elif goal_type == GoalType.heal_ally:
            contribs = [EffectType.restore_health]
        else:
            raise Exception(f'GoalType {goal_type} has no configured contributing effects')

        return contribs


class Plan:
    def __init__(self):
        self.action = None
        self.debug = ''
        self.score = 0


class Enemy:
        # I omitted all the enemy member variables here not related to the problem, for brevity.
        # AI
        self.actions = actions
        self.goals = goals

    def take_a_turn(self, fight):
        plans = self.get_action_plans(fight)

        if len(plans) > 0:
            print(f'{self.name}\'s plans:')
            for plan in plans:
                print(': ' + plan.debug)

            plans.sort(key=lambda x: x.score, reverse=True)
            the_plan = plans[0]
            print(f'The chosen plan is: --{the_plan.debug}-- w/ score {the_plan.score}')
            return the_plan.action()
        else:
            return f'{self.name} took no action.'

    def get_action_plans(self, fight):
        plans = self.get_kill_player_action_plans(fight)

        if len(plans) > 0:
            return plans

        # damage_player goal
        goal = [x for x in self.goals if x.goal_type == GoalType.damage_player]

        if len(goal) > 0:
            plans += self.get_damage_player_plans(goal[0], fight)

        # heal_ally goal
        goal = [x for x in self.goals if x.goal_type == GoalType.heal_ally]

        if len(goal) > 0:
            plans += self.get_heal_ally_plans(goal[0], fight)

        return plans

    def get_damage_player_plans(self, goal, fight):
        plans = []

        for action in self.actions:
            if action.targets_players and action.is_usable(fight.states):
                effects = list(filter(lambda effect: effect.type == EffectType.damage_health, action.effects))

                if len(effects) > 0:
                    for character in fight.characters:
                        dmg = character.estimate_damage_from_enemy_action(self, action)
                        plan = Plan()
                        plan.score = goal.value + int(100.0 * dmg / character.health)
                        plan.action = lambda: action.do(user=self, target=character, fight=fight)
                        plan.debug = f'damage {character.name} w/ {action.name} score {plan.score}'
                        plans.append(plan)

        return plans

    def get_heal_ally_plans(self, goal, fight):
        plans = []

        for action in self.actions:
            if action.targets_allies and action.is_usable(fight.states):
                effects = list(filter(lambda effect: effect.type == EffectType.restore_health, action.effects))

                if len(effects) > 0:
                    for enemy in fight.enemies:
                        plan = Plan()
                        plan.score = goal.value + 100 - int(enemy.current_health / enemy.health * 100)
                        plan.action = lambda: action.do(user=self, target=enemy, fight=fight)
                        plan.debug = f'heal {enemy.name} w/ {action.name} score {plan.score}'
                        plans.append(plan)

        return plans

The Enemy used for testing--ignore all the numbers, which are just various stats

enemies = {
    'slime': Enemy('Slime', 1, 0.3, 1, 0.3, 1, 0.3, 1, 0.3, 10, 0.1, 5, 0.2, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0,
                   [SingleTargetAttack('Headbutt', '', 0, 0.05,
                                       [SpellEffect(EffectType.damage_health, Elements.earth, 1, 4)]),
                    SingleTargetHeal('Regenerate', '', 3,
                                     [SpellEffect(EffectType.restore_health, Elements.water, 2, 5)])],
                   [Goal(GoalType.damage_player, 500), Goal(GoalType.heal_ally, 450)]),
}

How an ability and its do() function is defined

class SingleTargetHeal(Action):
    def __init__(self, name: str, description: str, cooldown: int, effects: [SpellEffect]):
        for effect in effects:
            if effect.type != EffectType.restore_health:
                raise Exception(f'SingleTargetHeal {name} has an unsupported effect type {effect.type}')

        super().__init__()
        self.name = name
        self.description = description
        self.cooldown = cooldown
        self.effects = effects
        self.targets_players = False
        self.targets_allies = True
        self.area = 0
        self.area_modifiable = False

    def do(self, user, target, fight):
        out = f'{user.name} used {self.name} on {target.name}.'
        targets = [target]

        if self.area > 0:
            i = self.area

            while i > 0:
                if fight.enemies.index(target) + i <= len(fight.enemies) - 1:
                    targets.append(fight.enemies.index(target) + i)

                if fight.enemies.index(target) - i > 0:
                    targets.insert(0, fight.enemies.index(target) - i)

                i -= 1

        for target in targets:
            for effect in self.effects:
                heal = target.restore_health(random.randint(effect.min, effect.max), user)
                out += f'\n{target.name} regained {heal} health.'

        return out

The base class, Action, defines is_usable() which definitely works correctly. The SingleTargetAttack action is essentially the same as SingleTargetHeal, but attacks players rather than healing enemies. It works, because if I remove the Regenerate spell and the heal_ally goal, the enemy can only make on plan, the attack, and does so correctly.

The debug statements from the planner

Slime's plans:
: damage Player w/ Headbutt score 502
: heal Slime w/ Regenerate score 450
The chosen plan is: --damage justindz#4247 w/ Headbutt score 502-- w/ score 502

What actually happens

Pico Slime goes next.
Pico Slime used Regenerate on you.
You regained 3 health.

As you can see, when the chosen plan's action is invoked, the enemy actually uses Regenerate on the player. Argh. That should not be possible, because it is not one of the valid plans, hence my suspicion that it is scope/binding-related. What am I missing?

  • Does this answer your question? [How do lexical closures work?](https://stackoverflow.com/questions/233673/how-do-lexical-closures-work) – Carcigenicate Jul 13 '21 at 20:06
  • "my suspicion that it is scope/binding-related": you're right. The variables in the `lambda` aren't "frozen" when the lambda is created. When `action` is reassigned in the next iteration of the loop, it will change the object pointed to in the previously-created `lambda`s as well. See the suggested dupe for ways around this. Piro's answer is the way I typically use; although it's a tad cryptic. – Carcigenicate Jul 13 '21 at 20:06
  • Any chance you could suggest the correct syntax? I have tried about 10 different variations recommended for assigning the function/lambda to the variable for later execution with early binding and they all have the same result. I read both of those articles and tried variation 11 and same result. Here's what I attempted based on those suggestions: `def do(user=self, target=enemy, f=fight): return action.do(user, target, f) plan.action = do` I'm not understanding something. – Justin Duewel-Zahniser Jul 13 '21 at 20:17
  • 1
    It's the creation of the `lambda` that you need to alter; not the definition of `do`. `plan.action = lambda action=action, character=character: action.do(user=self, target=character, fight=fight)` should do it. – Carcigenicate Jul 13 '21 at 20:19
  • My hero! I genuinely had tried 11 (maybe more) variations on this, and I think what I was missing was early-binding action itself. Thank you so much. – Justin Duewel-Zahniser Jul 13 '21 at 20:25
  • No problem. I would definitely read that post I mentioned though, along with Piro's answer to understand why the above suggestion works. – Carcigenicate Jul 13 '21 at 20:26
  • Yes, I had my suspicions because I had read the language spec and everything I could find on early-binding a lambda through defaults. They all were very similar to Piro's answer, but all cryptic because they were "fake" examples and it was hard to connect the dots to the action itself needing to be defaulted in. I definitely read that article and the associated article it recommended, and the debates on pythonic approaches, as you suggested :-) – Justin Duewel-Zahniser Jul 13 '21 at 20:28
  • **Please** read [mre]. – Karl Knechtel Aug 19 '22 at 12:15

0 Answers0