wesnoth/data/ai/lua/ca_high_xp_attack.lua
mattsc 626d7ee381 AI: add [filter_own] to all default AI candidate actions
This allows restricting each CA individually to only a subset of the units. It also means that several instances of the same CA can be used in the same AI configuration, in order to, for example, force the order in which units are dealt with, or to apply different settings to different units.
2019-11-21 12:09:33 -08:00

300 lines
13 KiB
Lua

local H = wesnoth.require "helper"
local AH = wesnoth.require "ai/lua/ai_helper.lua"
local LS = wesnoth.require "location_set"
local M = wesnoth.map
-- 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 XP_attack
local ca_attack_highxp = {}
function ca_attack_highxp:evaluation(cfg, data, filter_own)
-- Note: (most of) the code below is set up to maximize speed. Do not
-- "simplify" this unless you understand exactly what that means
local attacks_aspect = ai.aspects.attacks
local max_unit_level = 0
local units = {}
for _,unit in ipairs(attacks_aspect.own) do
if (unit.attacks_left > 0) and (#unit.attacks > 0) and (not unit.canrecruit) and unit:matches(filter_own) then
table.insert(units, unit)
local level = unit.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(attacks_aspect.enemy) do
if AH.is_attackable_enemy(enemy) then
local XP_to_levelup = enemy.max_experience - enemy.experience
if (max_unit_level * wesnoth.game_config.combat_experience >= XP_to_levelup) then
local potential_target = false
local ind_attackers, ind_other_units = {}, {}
for i_u,unit in ipairs(units) do
if (M.distance_between(enemy.x, enemy.y, unit.x, unit.y) <= unit.moves + 1) then
if (unit.level * wesnoth.game_config.combat_experience >= 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 unit:clone() once per unit
local reaches = LS.create()
local attacker_copies = LS.create()
local aggression = ai.aspects.aggression
local avoid_map = LS.of_pairs(ai.aspects.avoid)
local max_ca_score, max_rating, best_attack = 0, 0
for _,target_info in ipairs(target_infos) do
local target = attacks_aspect.enemy[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
if (not avoid_map:get(xa, ya)) then
local unit_in_way = wesnoth.units.get(xa, ya)
if AH.is_visible_unit(wesnoth.current.side, 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.units.get(uiw_loc[1], uiw_loc[2])
if (not AH.is_visible_unit(wesnoth.current.side, 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
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 = attacker:clone()
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 * target.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 * attacker_copy.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
-- Also need to save weapon number because attack simulation above
-- is for a different situation than the actual units on the map.
-- +1 because of difference between Lua and C++ indices
best_attack.attack_num = att_weapon.attack_num + 1
end
end
end
end
if best_attack then
XP_attack = best_attack
end
return max_ca_score
end
function ca_attack_highxp:execution(cfg, data)
AH.robust_move_and_attack(ai, XP_attack.src, XP_attack.dst, XP_attack.target, { weapon = XP_attack.attack_num })
XP_attack = nil
end
return ca_attack_highxp