
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.
300 lines
13 KiB
Lua
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
|