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?