#!WPY import wesnoth, random ## Copyright 2006 by Michael Schmahl ## This code is available under the latest version of the GNU Public License. ## See COPYING for details. Some inspiration and code derived from "sample.py" ## by allefant. ## ## This is my attempt at a 'chess-like' AI. All moves are motivated by ## an underlying evaluation function. The actual eval function doesn't ## need to be coded, because moves can be scored and ranked based on the ## incremental change in the evaluation. Unlike a chess-playing program, ## though, this program does no lookahead, because the branching factor ## is prohibitively high (potentially in the thousands), and because then ## the script would have to create an internal model of the game state. ## ## Despite the lack of any lookahead, I still consider this AI to be ## chess-like because it evaluates every possible move and attack, even ## those that are obviously (to a human) bad. How can a computer know ## that these are bad moves unless it actually checks? ## ## The evaluation function is: ## ## (1) side_score = village_score ## + sum(unit_score, over all units) ## + positional_score ## ## The value of a unit can be highly subjective, but to simplify, assume ## that any level-1 unit is just as valuable as any other level-1 unit. ## Specifically, the value of a unit will be: ## ## (2) unit_score = (1 + level + %xp)(1 + %hp) ## ## Leaders are be considered three levels higher than their actual level. ## So a freshly-recruited level-1 unit is worth 4.0 points. And a level-2 ## unit with half its hitpoints remaining, but halfway to level 3, is ## worth 6.75 points. ## ## One question is: How much is a village worth, compared to a (typical) ## unit? A typical unit is worth 15 to 20 gold, because that is how much ## we paid for it. A village is worth two or three gold *per turn* as ## long as it is held. (The village is worth three gold when it offsets ## a unit's upkeep.) So we must make some assumptions as to the value of ## a present gold piece, compared to a future gold piece. Assume a decay ## rate of 1.5 (i.e. a gold piece one turn from now is worth two-thirds ## of a gold piece now). This makes the present value of a village equal ## to twice its income. If we set the value of a typical unit at 16 gold, ## we get that an upkeep-offsetting village is worth 1.5 points, and a ## supernumerary village is worth 1.0 points. For simplicity, the value ## of each village is set at 1.0. ## ## (3) village_score = number of villages ## ## The positional score is the most interesting term of equation (1), ## because that, more than anything else, will guide the AI's behavior. ## ## First, we want the AI to expand to capture villages. So, for each unit, ## it is scored based on how far it is from the nearest unowned or enemy ## village. If the distance is zero, the unit has actually captured the ## village, so in that limit, the value should be equal to the village ## value. As the distance approaces infinity, the score should tend ## toward zero. This suggests something like: ## ## (4) village_proximity = c / (c + distance) ## ## I have selected c to be equal to equal to the unit's movement. This ## means that (approximately) a unit one turn away from capturing a village ## gets 0.5 points; two turns, 0.33 points, etc. Although an exponential ## relationship would be more accurate, exponentiation is expensive, and ## better avoided, since thousands of moves are evaluated per turn. ## ## Second, we want units to stand on defensive terrain when within range ## of the enemy. The 'right' way to do this would be to count up all the ## potential attackers at the destination square, see how much damage they ## might do, and score the move based on how much damage would be dealt/ ## prevented. Again, this is much too slow. I have found a reasonable ## approximation is: ## ## (5) exposure_penalty = -defense_modifier / 10 ## ## Maybe much too simple, but easy to calculate! In future editions, perhaps ## I should take into account how damaged the unit is, or at least make some ## attempt to count the attackers. ## ## Third, we want units to heal when damaged or poisoned. Referring to ## equation (2), we can see that the value of healing is: ## ## (6) healing_score = healing / max_hitpoints * (1 + level + %xp) ## ## We consider poison, which does 8 damage *per turn*, to be equivalent to ## 16 points of actual damage, for the same reason a village's real value is ## twice its income (see above). ## ## Fourth, we want units to guard villages if the enemy is in range to take ## them. If, by stationing a unit on a village, we prevent the enemy from ## taking it, we have prevented a 2-point swing in the enemy's favor. Again ## considering a decay rate of 2/3 per turn, this means the garrison value ## is 4/3. But since there is no guarantee that our garrison will be ## successful (perhaps the enemy will take the village anyway; perhaps it is ## not possible to garrison all threatened villages), we will cut this in half. ## ## (7) garrison_score = 2/3 ## ## Fifth, we want our leader to stay near a keep. Otherwise, any gold we ## might have accumulated will be wasted. And finally, we want units to move ## toward the enemy leader. These are accomplished by treating keeps as ## if they were unowned villages (for our leader), and the enemy leader ## as if it were a village (for everyone else). ## ## This should be all that is required to play a decent game of Wesnoth. ## This AI scores quite well against the Wesnoth default AI, which may be ## surprising, because it uses no sophisticated tools. There is no attempt ## to use any of the path-finding tools made available by the API (which ## would be too slow to be used thousands of times every turn). There is ## no attempt to use combination attacks (meaning, that even though none of ## several units can favorably attack a certain target, if they all attack ## in the same turn, the result is likely to be favorable). No attempt is ## made to assign units individually to targets. ## ## Some bad behaviors may result from these shortcomings: ## ## If the map is maze-like, or simply has a few corners surrounded by ## impassable terrain, units may get stuck. On Cynsaun Battlefield, for ## example, a group of units got stuck in the middle of the river, trying ## to capture a village on the other side of the deep-water hexes. ## ## An enemy unit may get completely surrounded by friendly units, who are ## weak in comparison to the enemy, and our AI will make no attempt to kill ## the enemy unit. (Think six Wolf Riders surrounding an Orcish Grunt.) ## Usually one or more of these units will find something else to do, allowing ## a few Archers to take their place and start to wear down the Grunt. Or ## the Grunt will attack, getting damaged in the process, and creating a ## chance-to-kill for one of the Wolves. ## ## If there is an unoccupied village in a corner of the map, our AI will ## send every unit that is closer to the village than any other, to that ## village. Often, only one unit is necessary. Thus, harassing villages ## with scouts may be a much more viable strategy against this AI than ## against human players, or against the default AI. ## ## For those interested in results, I have set up a tournament between my ## AI and the default AI. The tournament consists of one match on each of ## the mainline two-player maps (except Wesbowl, naturally). In each map, ## each opponent is allowed to be player 1 once. If there is no decision ## after two games, two more games are played, repeating as necessary until ## one opponent has won the match. All games are played with a 50-turn ## limit, 2 gold per village, 70% experience, and no fog. (I think there ## is a bug (feature?) that AIs ignore fog, so I disabled it to improve the ## observer's (my) experience.) Factions are chosen randomly. ## ## Map W-L-D %Win Match result ## Blitz 2-0-0 100 Win ## Caves of the Basilisk 4-2-0 67 Win ## Charge 3-1-0 75 Win ## Cynsaun Battlefield (1gpv) 2-0-0 100 Win ## Den of Onis 4-2-0 67 Win ## Hamlets 2-0-0 100 Win ## Hornshark Island 0-2-0 0 Loss ## Meteor Lake 2-0-0 100 Win ## Sablestone Delta 2-0-0 100 Win ## Silverhead Crossing 3-1-0 75 Win ## Sulla's Ruins 2-0-0 100 Win ## ** Overall 25-8-0 76 10 Wins, 1 Loss (91%) # UNIT SCORE MODIFIERS BASE_UNIT_SCORE = 1 # Base worth of a unit LEVEL_SCORE = 1 # Worth/level LEADER_SCORE = 3 # Leader worth FULL_XP_SCORE = 1 # How much is partial XP worth (1 is 100% XP = 1 pt) # This score is then multiplied by a factor dependant on the price of the unit # this makes expensive units worth more to the AI COST_SCORE = 0 # BASE_COST_SCORE = 1 # # Formula: # Base_Score = BASE_UNIT_SCORE + level * LEVEL_SCORE + is_leader * LEADER_SCORE + xp/max_xp * FULL_XP_SCORE # Cost_Modifier = BASE_COST_SCORE + price * COST_SCORE # Unit_Score(unit_k) = Base_Score * Cost_Modifier # POSITION SCORE MODIFIERS NO_MOVE_PEN = 0 # Penalty for not moving (doesn't quite work) NEXT_TO_ENEMY_PEN = 0 # Penalty for moving next to an enemy and not attacking STAND_NEXT_TO_ENEMY_PEN = 0 # Penalty for standing next to an enemy without moving or attacking # MISC SCORE MODIFIERS LEVEL_CHANCE_BONUS = 0 # How much a level-up is worth VILLAGE_SCORE = 1 # How much capturing a village is worth ENEMY_VILLAGE_BONUS = 1 # How much extra is an enemy village worth GARRISON_SCORE = 2.0/3 # How much defending a village is worth DEFENSE_FACTOR = 1.0/1000 # How much to penalize a unit for being in an attackable position HEAL_FACTOR = 1 # How much is healing worth HEAL_ATTACKABLE = .5 # How much relative to healing is healing when attackable worth HEAL_POISON = 16 # How much is healing from poison worth HP_SCALE = .1 # Unit HP/turn (for recruitment) def pos(p): if p==None: return ("Nowhere") return ("(%s,%s)"%(p.x+1,p.y+1)) class AI: def __init__(self): self.get_villages() self.get_keeps() self.mapsize = max((wesnoth.get_map().x,wesnoth.get_map().y)) / 30.0 self.stats = [0,0] def report_stats(self): wesnoth.log_message("%d moves, %d fights evaluated" % (self.stats[0],self.stats[1])) def get_villages(self): self.notmyvillages = [] m = wesnoth.get_map() for x in range(m.x): for y in range(m.y): loc = wesnoth.get_location(x,y) if m.is_village(loc): for team in wesnoth.get_teams(): if team.owns_village(loc) and not team.is_enemy: break else: self.notmyvillages.append(loc) def get_keeps(self): self.keeps = [] m = wesnoth.get_map() for x in range(m.x): for y in range(m.y): loc = wesnoth.get_location(x,y) if m.is_keep(loc): # If the enemy is occupying the keep, it is "off-limits" to our leader. # Otherwise, if our leader has strayed too far, it might attempt to go # to the enemy keep, which basically means we lose. if loc not in wesnoth.get_enemy_destinations_by_unit().keys(): self.keeps.append(loc) def recruit(self): # I haven't discussed this at all. Perhaps a few paragraphs would be in order. if wesnoth.get_current_team().gold < 16: return # find our leader leaderpos = None for location,unit in wesnoth.get_units().iteritems(): if unit.can_recruit and unit.side == wesnoth.get_current_team().side: leaderpos = location break # no leader? can't recruit if leaderpos == None: return # is our leader on a keep? If not, move to a keep # Maybe should always go to nearest keep if not leaderpos in self.keeps: for dest in wesnoth.get_destinations_by_unit().get(leaderpos,[]): if dest in self.keeps: leaderpos = wesnoth.move_unit(leaderpos,dest) break # is our leader on a keep now? If not, can't recruit if leaderpos not in self.keeps: return # build up a list of recruits and scores for each recruit_list = [] sumweights = 0 for recruit in wesnoth.get_current_team().recruits(): weight = self.recruit_score(recruit) if weight < 0.01: weight = 0.01 recruit_list.append((recruit.name,weight)) sumweights += weight # repeatedly recruit until we fail while 1: # pick a random recruit in proportion to the weights r = random.uniform(0,sumweights) for recruit,weight in recruit_list: r -= weight if r < 0: break # just use leaderpos for the location; wesnoth will always # recruit on the nearest adjacent tile if not wesnoth.recruit_unit(recruit,leaderpos): break def map_score(self,recruit): # calculate average speed in hexes/turn # and average defense in effective hp m = wesnoth.get_map() n = m.x * m.y speed = 0.0 defense = 0.0 for x in range(m.x): for y in range(m.y): loc = wesnoth.get_location(x,y) speed += 1.0 / recruit.movement_cost(loc) defense += 100.0 / recruit.defense_modifier(loc) - 1 # speed is more important on larger maps speed *= self.mapsize * recruit.movement / n # scaled down because effective hp is over the lifetime of the unit, # while other scores are based on per-turn quantities defense *= HP_SCALE * recruit.hitpoints / n return speed,defense def combat_score(self,recruit): # combat advantage, in hp/turn, averaged over all enemy units tot = 0.0 n = 0 for loc,enem in wesnoth.get_units().iteritems(): if not enem.is_enemy: continue n += 1 tot += self.combat_advantage(recruit,enem) tot -= self.combat_advantage(enem,recruit) return tot/n def combat_advantage(self,attacker,defender): # combat advantage for attacker attacking defender best = 0.0 for weapon in attacker.attacks(): damage = weapon.damage * weapon.num_attacks * defender.damage_from(weapon) / 100.0 best_retal = 0.0 for retaliation in defender.attacks(): if weapon.range == retaliation.range: retal = retaliation.damage * retaliation.num_attacks * attacker.damage_from(retaliation) / 100.0 if retal > best_retal: best_retal = retal damage -= best_retal if damage > best: best = damage # scale down because not every attack hits return best/2 def recruit_score(self,recruit): speed,defense = self.map_score(recruit) combat = self.combat_score(recruit) rval = (speed + defense + combat)/recruit.cost # only report "interesting" results if rval > 0: wesnoth.log_message("%s: (%.2f + %.2f + %.2f) / %d = %.3f" % (recruit.name,speed,defense,combat,recruit.cost,rval)) return rval def do_one_move(self): enemlocs = wesnoth.get_enemy_destinations_by_unit().keys() self.enemdests = wesnoth.get_enemy_units_by_destination().keys() bestmove = (0,None,None,None) # score,orig,dest,target # find the best move for orig in wesnoth.get_destinations_by_unit().keys(): # get a baseline score for this unit "standing pat" base_score = self.eval_move(orig,orig) for dest in wesnoth.get_destinations_by_unit()[orig]: # Bug workaround -- if we have recruited this turn, # get_destinations_by_unit() is incorrect if dest in wesnoth.get_units().keys() and dest != orig: continue score = self.eval_move(orig,dest) - base_score if score > bestmove[0]: bestmove = (score,orig,dest,dest) for target in wesnoth.get_adjacent_tiles(dest): if target in enemlocs: fight = self.eval_fight(wesnoth.get_units()[orig],dest,target)+score if orig == dest: fight += STAND_NEXT_TO_ENEMY_PEN + NO_MOVE_PEN else: fight += NEXT_TO_ENEMY_PEN if fight > bestmove[0]: bestmove = (fight,orig,dest,target) if bestmove[1] == None: # no move improved the position, therefore we are done return False score,orig,dest,target = bestmove wesnoth.log_message("%.3f: %s->%s@%s"%(score,pos(orig),pos(dest),pos(target))) if dest != orig: wesnoth.move_unit(orig,dest) if dest in self.notmyvillages: self.notmyvillages.remove(dest) if target != dest: wesnoth.attack_unit(dest,target) return True def eval_fight(self,unit,dest,target): self.stats[1] += 1 enem = wesnoth.get_units().get(target,None) if not enem: return 0 # the base value for each unit: # I should give more weight to defeating a garrison unit_k = (LEVEL_SCORE*unit.type().level + BASE_UNIT_SCORE + LEADER_SCORE*unit.can_recruit\ + FULL_XP_SCORE * unit.experience * 1.0 / unit.max_experience) * (BASE_COST_SCORE + unit.type().cost * COST_SCORE) enem_k = (LEVEL_SCORE*enem.type().level + BASE_UNIT_SCORE + LEADER_SCORE*enem.can_recruit\ + FULL_XP_SCORE * enem.experience * 1.0 / enem.max_experience) * (BASE_COST_SCORE + enem.type().cost * COST_SCORE) unit_hp,enem_hp = unit.attack_statistics(dest,target) score = 0.0 for hp,p in enem_hp.iteritems(): score += p * (enem.hitpoints - hp) * enem_k / enem.max_hitpoints if hp<=0: score += p * enem_k for hp,p in unit_hp.iteritems(): score -= p * (unit.hitpoints - hp) * unit_k / unit.max_hitpoints if hp<=0: score -= p * unit_k enem_xp = 8*enem.type().level if enem.type().level == 0: enem_xp = 4 unit_xp = 8*unit.type().level if unit.type().level == 0: unit_xp = 4 if enem.type().level >= unit.max_experience - unit.experience: for hp, p in unit_hp.iteritems(): if hp > 0: score += LEVEL_CHANCE_BONUS * p * unit_k elif enem_xp >= unit.max_experience - unit.experience: for hp, p in enem_hp.iteritems(): if hp <= 0: score += LEVEL_CHANCE_BONUS * p * unit_k if unit.type().level >= enem.max_experience - enem.experience: for hp, p in enem_hp.iteritems(): if hp > 0: score -= LEVEL_CHANCE_BONUS * p * enem_k elif unit_xp >= enem.max_experience - enem.experience: for hp, p in unit_hp.iteritems(): if hp <= 0: score += LEVEL_CHANCE_BONUS * p * enem_k return score def eval_move(self,orig,dest): enemlocs = wesnoth.get_enemy_destinations_by_unit().keys() self.stats[0] += 1 score = 0.0 unit = wesnoth.get_units().get(orig,None) if not unit: return unit_k = (LEVEL_SCORE*unit.type().level + BASE_UNIT_SCORE + LEADER_SCORE*unit.can_recruit\ + FULL_XP_SCORE * unit.experience * 1.0 / unit.max_experience) * (BASE_COST_SCORE + unit.type().cost * COST_SCORE) # subtract 1 because terrain might be a factor speed = unit.type().movement - 1 attackable=False if dest in self.enemdests: attackable = True else: for adj in wesnoth.get_adjacent_tiles(dest): if adj in self.enemdests: attackable = True break # capture villages if dest in self.notmyvillages: score += VILLAGE_SCORE for team in wesnoth.get_teams(): if team.owns_village(dest) and team.is_enemy: score += ENEMY_VILLAGE_BONUS bestdist=100 if unit.can_recruit: # leader stays near keep for keep in self.keeps: dist=dest.distance_to(keep) if dist 1: for vil in self.notmyvillages: if dest==vil: continue dist=dest.distance_to(vil) if dist 8: healing = 8 # reduce the healing bonus if we might get killed first if attackable: healing *= HEAL_ATTACKABLE score += HEAL_FACTOR * healing * unit_k / unit.max_hitpoints if attackable: # defense score -= unit.defense_modifier(dest) * DEFENSE_FACTOR # garrison if wesnoth.get_map().is_village(dest): score += GARRISON_SCORE # reduce chances of standing next to a unit without attacking for a whole turn if dest == orig: score -= NO_MOVE_PEN for target in wesnoth.get_adjacent_tiles(dest): if target in enemlocs: score -= STAND_NEXT_TO_ENEMY_PEN break else: for target in wesnoth.get_adjacent_tiles(dest): if target in enemlocs: score -= NEXT_TO_ENEMY_PEN break # end mod return score ai = AI() ai.recruit() while 1: if not ai.do_one_move(): break ai.recruit() ai.report_stats()