Merge pull request #764 from wesnoth/ai_high_xp_attack

New high_xp_attack candidate action for default AI
This commit is contained in:
mattsc 2016-09-04 07:02:40 -07:00 committed by GitHub
commit 56d30fc82b
19 changed files with 509 additions and 52 deletions

View file

@ -1,5 +1,8 @@
Version 1.13.5+dev:
* AI:
* Added new high_xp_attack candidate action to default AI. This CA performs
attacks on enemy units so close to leveling that the default AI's combat CA
would not attack them.
* New Micro AI: Assassin Squad AI
* Campaigns:
* Eastern Invasion:

View file

@ -15,6 +15,7 @@
{AI_CA_RECRUITMENT}
{AI_CA_MOVE_LEADER_TO_GOALS}
{AI_CA_MOVE_LEADER_TO_KEEP}
{AI_CA_HIGH_XP_ATTACK}
{AI_CA_COMBAT}
{AI_CA_HEALING}
{AI_CA_VILLAGES}

View file

@ -16,6 +16,7 @@
{AI_CA_RECRUITMENT}
{AI_CA_MOVE_LEADER_TO_GOALS}
{AI_CA_MOVE_LEADER_TO_KEEP}
{AI_CA_HIGH_XP_ATTACK}
{AI_CA_COMBAT}
{AI_CA_HEALING}
{AI_CA_VILLAGES}

View file

@ -22,6 +22,7 @@
#{AI_CA_RECRUITMENT}
{AI_CA_MOVE_LEADER_TO_GOALS}
{AI_CA_MOVE_LEADER_TO_KEEP}
{AI_CA_HIGH_XP_ATTACK}
{AI_CA_COMBAT}
{AI_CA_HEALING}
{AI_CA_VILLAGES}

View file

@ -14,6 +14,7 @@
{AI_CA_RECRUITMENT}
{AI_CA_MOVE_LEADER_TO_GOALS}
{AI_CA_MOVE_LEADER_TO_KEEP}
{AI_CA_HIGH_XP_ATTACK}
{AI_CA_COMBAT}
{AI_CA_HEALING}
{AI_CA_VILLAGES}

View file

@ -0,0 +1,300 @@
local H = wesnoth.require "lua/helper.lua"
local AH = wesnoth.require "ai/lua/ai_helper.lua"
local LS = wesnoth.require "lua/location_set.lua"
-- Evaluation process:
--
-- Find all enemy units that could be caused to level up by an attack
-- - If only units that would cause them to level up can attack, CA score = 100,010.
-- This means the attack will be done before the default AI attacks, so that AI
-- units do not get used otherwise by the default AI.
-- - If units that would not cause a leveling can also attack, CA score = 99,990,
-- meaning we see whether the default AI attacks that unit with one of those first.
-- We also check whether it is possible to move an own unit out of the way
--
-- Attack rating:
-- 0. If the CTD (chance to die) of the AI unit is larger than the value of
-- aggression for the side, do not do the attack
-- 1. Otherwise, if the attack might result in a kill, do that preferentially:
-- rating = CTD of defender - CTD of attacker
-- 2. Otherwise, if the enemy is poisoned, do not attack (wait for it
-- weaken and attack on a later turn)
-- 3. Otherwise, calculate damage done to enemy (as if it were not leveling) and
-- own unit, expressed in partial loss of unit value (gold) and minimize both.
-- Damage to enemy is minimized because we want to level it with the weakest AI unit,
-- so that we can follow up with stronger units. In addition, use of poison or
-- slow attacks is strongly discouraged. See code for exact equations.
local ca_attack_highxp = {}
function ca_attack_highxp:evaluation(cfg, data)
-- Note: (most of) the code below is set up to maximize speed. Do not
-- "simplify" this unless you understand exactly what that means
-- E.g., getting all units, plus looping over them, is much faster than using a filter
local all_units = wesnoth.get_units()
local max_unit_level = 0
local units = {}
for _,unit in ipairs(all_units) do
if (unit.side == wesnoth.current.side) and (unit.attacks_left > 0) and (#unit.attacks > 0) then
table.insert(units, unit)
local level = wesnoth.unit_types[unit.type].level
if (level > max_unit_level) then
max_unit_level = level
end
end
end
if (not units[1]) then return 0 end
-- Mark enemies as potential targets if they are close enough to an AI unit
-- that could trigger them leveling up; this is not a sufficient criterion,
-- but it is much faster than path finding, so it is done for preselection.
local target_infos = {}
for i_t,enemy in ipairs(all_units) do
if wesnoth.is_enemy(wesnoth.current.side, enemy.side) then
local XP_to_levelup = enemy.max_experience - enemy.experience
if (max_unit_level >= XP_to_levelup) then
local potential_target = false
local ind_attackers, ind_other_units = {}, {}
for i_u,unit in ipairs(units) do
if (H.distance_between(enemy.x, enemy.y, unit.x, unit.y) <= unit.moves + 1) then
if (wesnoth.unit_types[unit.type].level >= XP_to_levelup) then
potential_target = true
table.insert(ind_attackers, i_u)
else
table.insert(ind_other_units, i_u)
end
end
end
if potential_target then
local target_info = {
ind_target = i_t,
ind_attackers = ind_attackers,
ind_other_units = ind_other_units
}
table.insert(target_infos, target_info)
end
end
end
end
if (not target_infos[1]) then return 0 end
-- The following location sets are used so that we at most need to call
-- find_reach() and wesnoth.copy_unit() once per unit
local reaches = LS.create()
local attacker_copies = LS.create()
local aggression = ai.get_aggression()
local max_ca_score, max_rating, best_attack = 0, 0
for _,target_info in ipairs(target_infos) do
local target = all_units[target_info.ind_target]
local can_force_level = {}
local attack_hexes = LS.create()
for xa,ya in H.adjacent_tiles(target.x, target.y) do
local unit_in_way = wesnoth.get_unit(xa, ya)
if unit_in_way then
if (unit_in_way.side == wesnoth.current.side) then
local uiw_reach
if reaches:get(unit_in_way.x, unit_in_way.y) then
uiw_reach = reaches:get(unit_in_way.x, unit_in_way.y)
else
uiw_reach = wesnoth.find_reach(unit_in_way)
reaches:insert(unit_in_way.x, unit_in_way.y, uiw_reach)
end
-- Check whether the unit to move out of the way has an unoccupied hex to move to.
-- We do not deal with cases where a unit can move out of the way for a
-- unit that is moving out of the way of the initial unit (etc.).
local can_move = false
for _,uiw_loc in ipairs(uiw_reach) do
-- Unit in the way of the unit in the way
local uiw_uiw = wesnoth.get_unit(uiw_loc[1], uiw_loc[2])
if (not uiw_uiw) then
can_move = true
break
end
end
if (not can_move) then
-- Keep this case as the unit in the way might be a potential attacker
attack_hexes:insert(xa, ya, unit_in_way.id)
else
attack_hexes:insert(xa, ya, 'can_move_away')
end
end
else
attack_hexes:insert(xa, ya, 'empty')
end
end
attack_hexes:iter(function(xa, ya, occupied)
for _,i_a in ipairs(target_info.ind_attackers) do
local attacker = units[i_a]
if (occupied == 'empty') or (occupied == 'can_move_away') then
-- If the hex is not blocked, check all potential attackers
local reach
if reaches:get(attacker.x, attacker.y) then
reach = reaches:get(attacker.x, attacker.y)
else
reach = wesnoth.find_reach(attacker)
reaches:insert(attacker.x, attacker.y, reach)
end
for _,loc in ipairs(reach) do
if (loc[1] == xa) and (loc[2] == ya) then
local tmp = {
ind_attacker = i_a,
dst = { x = xa, y = ya },
src = { x = attacker.x, y = attacker.y }
}
table.insert(can_force_level, tmp)
break
end
end
else
-- If hex is blocked by own units, check whether this unit
-- is one of the potential attackers
if (attacker.id == occupied) then
local tmp = {
ind_attacker = i_a,
dst = { x = xa, y = ya },
src = { x = attacker.x, y = attacker.y }
}
table.insert(can_force_level, tmp)
end
end
end
end)
-- If a leveling attack is possible, check whether any of the other
-- units (those with too low a level to force leveling up) can get there
local ca_score = 100010
attack_hexes:iter(function(xa, ya, occupied)
if (ca_score == 100010) then -- cannot break out of the iteration with goto
for _,i_u in ipairs(target_info.ind_other_units) do
local unit = units[i_u]
if (occupied == 'empty') or (occupied == 'can_move_away') then
-- If the hex is not blocked, check if unit can get there
local reach
if reaches:get(unit.x, unit.y) then
reach = reaches:get(unit.x, unit.y)
else
reach = wesnoth.find_reach(unit)
reaches:insert(unit.x, unit.y, reach)
end
for _,loc in ipairs(reach) do
if (loc[1] == xa) and (loc[2] == ya) then
ca_score = 99990
goto found_unit
end
end
else
-- If hex is blocked by own units, check whether this unit
-- is one of the potential attackers
if (unit.id == occupied) then
ca_score = 99990
goto found_unit
end
end
end
-- It is sufficient to find one unit that can get to any attack hex
::found_unit::
end
end)
if (ca_score >= max_ca_score) then
for _,attack_info in ipairs(can_force_level) do
local attacker = units[attack_info.ind_attacker]
local attacker_copy
if attacker_copies:get(attacker.x, attacker.y) then
attacker_copy = attacker_copies:get(attacker.x, attacker.y)
else
attacker_copy = wesnoth.copy_unit(attacker)
attacker_copies:insert(attacker.x, attacker.y, attacker_copy)
end
attacker_copy.x = attack_info.dst.x
attacker_copy.y = attack_info.dst.y
-- Choose the attacker that would do the *least* damage.
-- We want the damage distribution here as if the target were not to level up
-- the chance to die is the same in either case
local old_experience = target.experience
target.experience = 0
local att_stats, def_stats, att_weapon = wesnoth.simulate_combat(attacker_copy, target)
target.experience = old_experience
local rating = -1000
if (att_stats.hp_chance[0] <= aggression) then
if (def_stats.hp_chance[0] > 0) then
rating = 5000 + def_stats.hp_chance[0] - att_stats.hp_chance[0]
elseif target.status.poisoned then
rating = -1002
else
rating = 1000
local enemy_value_loss = (target.hitpoints - def_stats.average_hp) / target.max_hitpoints
enemy_value_loss = enemy_value_loss * wesnoth.unit_types[target.type].cost
-- We want the _least_ damage to the enemy, so the minus sign is no typo!
rating = rating - enemy_value_loss
local own_value_loss = (attacker_copy.hitpoints - att_stats.average_hp) / attacker_copy.max_hitpoints
own_value_loss = own_value_loss + att_stats.hp_chance[0]
own_value_loss = own_value_loss * wesnoth.unit_types[attacker_copy.type].cost
rating = rating - own_value_loss
-- Strongly discourage poison or slow attacks
if att_weapon.poisons or att_weapon.slows then
rating = rating - 100
end
-- Minor penalty if the attack hex is occupied
if (attack_hexes:get(attack_info.dst.x, attack_info.dst.y) == 'can_move_away') then
rating = rating - 0.001
end
end
end
if (rating > max_rating)
or ((rating > 0) and (ca_score > max_ca_score))
then
max_rating = rating
max_ca_score = ca_score
best_attack = attack_info
best_attack.target = { x = target.x, y = target.y }
best_attack.ca_score = ca_score
end
end
end
end
if best_attack then
data.XP_attack = best_attack
end
return max_ca_score
end
function ca_attack_highxp:execution(cfg, data)
local attacker = wesnoth.get_unit(data.XP_attack.src.x, data.XP_attack.src.y)
local defender = wesnoth.get_unit(data.XP_attack.target.x, data.XP_attack.target.y)
AH.movefull_outofway_stopunit(ai, attacker, data.XP_attack.dst.x, data.XP_attack.dst.y)
if (not attacker) or (not attacker.valid) then return end
if (not defender) or (not defender.valid) then return end
AH.checked_attack(ai, attacker, defender)
data.XP_attack = nil
end
return ca_attack_highxp

View file

@ -7,7 +7,7 @@ function ca_healer_may_attack:evaluation()
-- After attacks by all other units are done, reset things so that healers can attack, if desired
-- This will be blacklisted after first execution each turn
local score = 99990
local score = 99900
return score
end

View file

@ -17,7 +17,7 @@ function ca_return_guardian:evaluation(cfg)
local guardian = get_guardian(cfg)
if guardian then
if (guardian.x == cfg.return_x) and (guardian.y == cfg.return_y) then
return cfg.ca_score - 20
return cfg.ca_score - 200
else
return cfg.ca_score
end

View file

@ -71,6 +71,11 @@ function wesnoth.micro_ais.fast_ai(cfg)
}
else
if (not cfg.skip_combat_ca) then
W.modify_ai {
side = cfg.side,
action = "try_delete",
path = "stage[main_loop].candidate_action[high_xp_attack]"
}
W.modify_ai {
side = cfg.side,
action = "try_delete",

View file

@ -34,7 +34,7 @@ function wesnoth.micro_ais.return_guardian(cfg)
local optional_keys = { "id", "[filter]" }
local CA_parms = {
ai_id = 'mai_return_guardian',
{ ca_id = 'move', location = 'ca_return_guardian.lua', score = cfg.ca_score or 100010 }
{ ca_id = 'move', location = 'ca_return_guardian.lua', score = cfg.ca_score or 100100 }
}
return required_keys, optional_keys, CA_parms
end

View file

@ -11,7 +11,7 @@ function wesnoth.micro_ais.healer_support(cfg)
-- The healers_can_attack CA is only added to the table if aggression ~= 0
-- But: make sure we always try removal
if (cfg.action == 'delete') or (tonumber(cfg.aggression) ~= 0) then
table.insert(CA_parms, { ca_id = 'may_attack', location = 'ca_healer_may_attack.lua', score = 99990 })
table.insert(CA_parms, { ca_id = 'may_attack', location = 'ca_healer_may_attack.lua', score = 99900 })
end
return {}, optional_keys, CA_parms
end

View file

@ -71,6 +71,7 @@ Gs^Fp , Gs^Fp , Wwf , Wwf , Mm , Rd
{AI_CA_GOTO}
{AI_CA_MOVE_LEADER_TO_GOALS}
{AI_CA_MOVE_LEADER_TO_KEEP}
{AI_CA_HIGH_XP_ATTACK}
{AI_CA_COMBAT}
{AI_CA_HEALING}
{AI_CA_VILLAGES}

View file

@ -0,0 +1,174 @@
#textdomain wesnoth-ai
[test]
id=high_xp_attack
name="High XP Attack"
map_data="{campaigns/The_Hammer_of_Thursagan/maps/12_The_Underlevels.map}"
{DEFAULT_SCHEDULE}
turns=-1
victory_when_enemies_defeated=yes
[side]
side=1
controller=human
id=player
name="Sly Player"
type=Necromancer
x,y=17,58
persistent=no
facing=sw
team_name=player
user_team_name="Sly Player"
recruit=Skeleton,Skeleton Archer
gold=0
[/side]
[side]
type=Dwarvish Steelclad
id=dwarf
name="Fearless AI Leader"
side=2
x,y=8,53
facing=se
team_name=dwarves
user_team_name="Fearless AI"
recruit=Dwarvish Fighter,Dwarvish Scout,Dwarvish Thunderer
gold=0
[/side]
# Prestart actions
[event]
name=prestart
[terrain]
x,y=21,55
terrain=Rd
[/terrain]
{UNIT 1 (Skeleton) 6 58 (random_traits,experience=no,33)}
{UNIT 2 (Dwarvish Scout) 6 56 (random_traits=no)}
{UNIT 2 (Dwarvish Scout) 4 58 (random_traits=no)}
{UNIT 2 (Dwarvish Fighter) 8 59 (random_traits=no)}
{UNIT 1 (Skeleton) 23 55 (random_traits,experience,hitpoints=no,34,10)}
{UNIT 2 (Dwarvish Scout) 27 54 (random_traits=no)}
# Groups of units with enemy 2 XP from leveling
{UNIT 1 (Revenant) 8 29 (random_traits,experience=no,83)}
{UNIT 2 (Dwarvish Scout) 5 29 (random_traits=no)}
{UNIT 2 (Dwarvish Scout) 5 30 (random_traits=no)}
{UNIT 2 (Dwarvish Steelclad) 6 29 (random_traits=no)}
{UNIT 1 (Revenant) 18 29 (random_traits,experience=no,83)}
{UNIT 2 (Dwarvish Fighter) 21 29 (random_traits,hitpoints=no,2)}
{UNIT 2 (Dwarvish Fighter) 21 30 (random_traits,hitpoints=no,2)}
{UNIT 2 (Dwarvish Runesmith) 20 29 (random_traits=no)}
# Move-out-of-way triple
{UNIT 1 (Skeleton) 20 54 (random_traits,experience=no,34)}
{UNIT 2 (Dwarvish Runesmith) 19 54 (random_traits=no)}
{UNIT 2 (Dwarvish Scout) 14 51 (random_traits=no)}
# Revenant - Fighter pairs
{UNIT 1 (Revenant) 32 41 (random_traits,experience=no,84)}
{UNIT 1 (Revenant) 46 41 (random_traits,experience,hitpoints=no,84,25)}
{UNIT 2 (Dwarvish Fighter) 35 40 (random_traits,hitpoints=no,7)}
{UNIT 2 (Dwarvish Fighter) 43 40 (random_traits,hitpoints=no,18)}
# Poisoned enemies
{UNIT 1 (Ghoul) 52 55 (random_traits,experience,hitpoints=no,34,16)}
[+unit]
[status]
poisoned=yes
[/status]
[/unit]
{UNIT 1 (Ghoul) 56 55 (random_traits,experience,hitpoints=no,34,24)}
[+unit]
[status]
poisoned=yes
[/status]
[/unit]
{UNIT 2 (Dwarvish Fighter) 54 54 (random_traits=no)}
{UNIT 2 (Dwarvish Fighter) 54 55 (random_traits=no)}
[/event]
[event]
name=start
[message]
id=player
message="Hahaha! I have placed my units at strategic choke points and given them XP close to leveling. I am safe from the stupid Wesnoth AI."
[/message]
[message]
id=dwarf
message="You're in for a nasty surprise ..."
[/message]
[message]
speaker=narrator
image="wesnoth-icon.png"
caption="Note"
message="This is a test scenario for a new AI algorithm that attacks units close to leveling. A few test cases are already set up on the map, but it is really expected that you add more units and/or change hitpoints and experience using debug commands to try out other situations."
[/message]
[message]
x,y=52,55
message="Poisoned units are only attacked if there is a chance to kill them. Otherwise we simply wait and let the poison do its work."
[/message]
[message]
x,y=19,54
message="The scout in the northwest is the better choice for forcing the skeleton to level up, so I'll move out of the way for him."
[/message]
[message]
x,y=8,29
message="Here we have a unit 2 XP from leveling with both L1 and L2 AI units in reach. In this case, we wait to see what the default AI does. After the default AI attacks with one of the L1 units and the enemy is 1 XP from leveling, we execute a level-up attack."
[/message]
[message]
x,y=18,29
message="This is an equivalent setup, except that the default AI chooses not to attack with the (weakened) L1 units. In this case, we execute the level-up attack with the L2 unit after the default AI is done."
[/message]
[message]
x,y=35,40
message="There's a high chance that I will die in attacking that revenant, so the AI will not attack with aggression=0.4 (the default). By contrast, with aggression=1 (which you can set in a moment), it does attack."
[/message]
[message]
x,y=43,40
message="I have a much lower chance to die and will attack even with the default setting for aggression."
[/message]
[message]
speaker=narrator
image=wesnoth-icon.png
message="What value should we use for aggression for the AI side?"
[option]
message="aggression 0.4 (the default)"
[/option]
[option]
message="aggression 1.0"
[command]
[modify_side]
side=2
[ai]
aggression=1
[/ai]
[/modify_side]
[/command]
[/option]
[/message]
[objectives]
[note]
description="Modify the units on the map as desired, then end the turn"
[/note]
[/objectives]
[/event]
[/test]

View file

@ -75,8 +75,6 @@
[/side]
{STARTING_VILLAGES 2 10}
{AI_FORCE_ATTACK_HIGH_XP_UNITS_SETUP}
{AI_FORCE_ATTACK_HIGH_XP_UNITS 2}
[side]
type=Orcish Slayer
@ -107,7 +105,6 @@
[/side]
{STARTING_VILLAGES 4 10}
{AI_FORCE_ATTACK_HIGH_XP_UNITS 4}
[side]
type=Elvish Marshal
@ -134,7 +131,6 @@
[/side]
{STARTING_VILLAGES 5 6}
{AI_FORCE_ATTACK_HIGH_XP_UNITS 5}
[side]
type=Elvish Marshal
@ -151,7 +147,6 @@
[/side]
{STARTING_VILLAGES 6 8}
{AI_FORCE_ATTACK_HIGH_XP_UNITS 6}
{SOTBE_TRACK {JOURNEY_04_NEW} }

View file

@ -70,8 +70,6 @@
[/side]
{STARTING_VILLAGES 2 28}
{AI_FORCE_ATTACK_HIGH_XP_UNITS_SETUP}
{AI_FORCE_ATTACK_HIGH_XP_UNITS 2}
[story]
[part]

View file

@ -199,44 +199,3 @@
[/unstore_unit]
[/event]
#enddef
#define AI_FORCE_ATTACK_HIGH_XP_UNITS_SETUP
# Function needed for Micro AI which forces attacks on units 1 XP from leveling
# Goes directly into scenario toplevel, but only once per scenario
[event]
name=preload
first_time_only=no
[lua]
code=<<
function close_to_advancing(unit)
if (unit.experience >= unit.max_experience-1) then
return true
else
return false
end
end
>>
[/lua]
[/event]
#enddef
#define AI_FORCE_ATTACK_HIGH_XP_UNITS SIDE
# Micro AI which forces attacks on units 1 XP from leveling
# Goes directly into scenario toplevel, one macro per side
[event]
name=prestart
[micro_ai]
side={SIDE}
ai_type=simple_attack
action=add
ca_score=100001
[filter]
canrecruit=no
[/filter]
[filter_second]
lua_function = "close_to_advancing"
[/filter_second]
[/micro_ai]
[/event]
#enddef

View file

@ -72,6 +72,7 @@
location="campaigns/The_Rise_Of_Wesnoth/ai/ca_aggressive_attack_no_suicide.lua"
[/candidate_action]
)}
{MODIFY_AI_DELETE_CANDIDATE_ACTION 2 main_loop high_xp_attack}
{MODIFY_AI_DELETE_CANDIDATE_ACTION 2 main_loop combat}
[/ai]
{FLAG_VARIANT wood-elvish}
@ -115,6 +116,7 @@
location="campaigns/The_Rise_Of_Wesnoth/ai/ca_aggressive_attack_no_suicide.lua"
[/candidate_action]
)}
{MODIFY_AI_DELETE_CANDIDATE_ACTION 3 main_loop high_xp_attack}
{MODIFY_AI_DELETE_CANDIDATE_ACTION 2 main_loop combat}
[/ai]
{FLAG_VARIANT knalgan}
@ -157,6 +159,7 @@
location="campaigns/The_Rise_Of_Wesnoth/ai/ca_aggressive_attack_no_suicide.lua"
[/candidate_action]
)}
{MODIFY_AI_DELETE_CANDIDATE_ACTION 4 main_loop high_xp_attack}
{MODIFY_AI_DELETE_CANDIDATE_ACTION 2 main_loop combat}
[/ai]
{FLAG_VARIANT long}

View file

@ -553,6 +553,7 @@
#{AI_CA_RECRUITMENT}
{AI_CA_MOVE_LEADER_TO_GOALS}
{AI_CA_MOVE_LEADER_TO_KEEP}
{AI_CA_HIGH_XP_ATTACK}
{AI_CA_COMBAT}
{AI_CA_HEALING}
{AI_CA_VILLAGES}

View file

@ -18,6 +18,10 @@
120000
#enddef
#define AI_CA_HIGH_XP_ATTACK_MAX_SCORE
100010
#enddef
#define AI_CA_COMBAT_SCORE
100000
#enddef
@ -93,6 +97,16 @@
[/candidate_action]
#enddef
#define AI_CA_HIGH_XP_ATTACK
[candidate_action]
id=high_xp_attack
engine=lua
name=ai_default_rca::high_xp_attack
location="ai/lua/ca_high_xp_attack.lua"
max_score={AI_CA_HIGH_XP_ATTACK_MAX_SCORE}
[/candidate_action]
#enddef
#define AI_CA_COMBAT
# RCA AI candidate action: combat
[candidate_action]