wesnoth/data/ai/lua/battle_calcs.lua
mattsc 507aa62e83 Lua AI: speed up battle_calcs.battle_outcome in extreme cases
The method used in the battle_outcome() function is somewhat simplified compared to the default engine calculation and faster in the vast majority of cases, but it can become extremely slow when the combined number of strikes of the attacker and defender is very large.
2022-08-31 12:42:10 -07:00

1612 lines
73 KiB
Lua

local AH = wesnoth.require "ai/lua/ai_helper.lua"
local LS = wesnoth.require "location_set"
local M = wesnoth.map
-- This is a collection of Lua functions used for custom AI development.
-- Note that this is still work in progress with significant changes occurring
-- frequently. Backward compatibility cannot be guaranteed at this time in
-- development releases, but it is of course easily possible to copy a function
-- from a previous release directly into an add-on if it is needed there.
local battle_calcs = {}
function battle_calcs.unit_attack_info(unit, cache)
-- Return a table containing information about attack-related properties of @unit
-- The result can be cached if variable @cache is given
-- This is done in order to avoid duplication of slow processes
-- Return table has fields:
-- - attacks: the attack tables
-- - resist_mod: resistance modifiers (multiplicative factors) index by attack type
-- - alignment: just that
-- Set up a cache index. We use id+max_hitpoints+side, since the
-- unit can level up. Side is added to avoid the problem of MP leaders sometimes having
-- the same id when the game is started from the command-line
local cind = 'UI-' .. unit.id .. unit.max_hitpoints .. unit.side
-- If cache for this unit exists, return it
if cache and cache[cind] then
return cache[cind]
end
-- Otherwise collect the information
local unit_info = {
attacks = {},
resist_mod = {},
alignment = unit.alignment
}
local attacks = unit.attacks
for i_a = 1,#attacks do
local attack = attacks[i_a]
-- Extract information for specials; we do this first because some
-- custom special might have the same name as one of the default scalar fields
local a = {}
for _,sp in ipairs(attack.specials) do
if (sp[1] == 'damage') then -- this is 'backstab'
if (sp[2].id == 'backstab') then
a.backstab = true
else
if (sp[2].id == 'charge') then a.charge = true end
end
else
-- magical, marksman
if (sp[1] == 'chance_to_hit') then
a[sp[2].id or 'no_id'] = true
else
a[sp[1]] = true
end
end
end
-- Now extract the scalar (string and number) values from attack
a.damage = attack.damage
a.type = attack.type
a.range = attack.range
-- number must be defined for battle_calcs.best_weapons()
a.number = attack.number or 0
table.insert(unit_info.attacks, a)
end
local attack_types = { "arcane", "blade", "cold", "fire", "impact", "pierce" }
for _,attack_type in ipairs(attack_types) do
unit_info.resist_mod[attack_type] = 1 - unit:resistance_against(attack_type) / 100.
end
if cache then cache[cind] = unit_info end
return unit_info
end
function battle_calcs.strike_damage(attacker, defender, att_weapon, def_weapon, dst, cache)
-- Return the single strike damage of an attack by @attacker on @defender
-- Also returns the other information about the attack (since we're accessing the information already anyway)
-- Here, @att_weapon and @def_weapon are the weapon numbers in Lua counts, i.e., counts start at 1
-- If @def_weapon = 0, return 0 for defender damage
-- This can be used for defenders that do not have the right kind of weapon, or if
-- only the attacker damage is of interest
-- @dst: attack location, to take terrain time of day, illumination etc. into account
-- For the defender, the current location is assumed
--
-- 'cache' can be given to cache strike damage and to pass through to battle_calcs.unit_attack_info()
-- Set up a cache index. We use id+max_hitpoints+side for each unit, since the
-- unit can level up.
-- Also need to add the weapons and lawful_bonus values for each unit
local att_lawful_bonus = wesnoth.schedule.get_illumination(dst).lawful_bonus
local def_lawful_bonus = wesnoth.schedule.get_illumination(defender).lawful_bonus
local cind = 'SD-' .. attacker.id .. attacker.max_hitpoints .. attacker.side
cind = cind .. 'x' .. defender.id .. defender.max_hitpoints .. defender.side
cind = cind .. '-' .. att_weapon .. 'x' .. def_weapon
cind = cind .. '-' .. att_lawful_bonus .. 'x' .. def_lawful_bonus
-- If cache for this unit exists, return it
if cache and cache[cind] then
return cache[cind].att_damage, cache[cind].def_damage, cache[cind].att_attack, cache[cind].def_attack
end
local attacker_info = battle_calcs.unit_attack_info(attacker, cache)
local defender_info = battle_calcs.unit_attack_info(defender, cache)
-- Attacker base damage
local att_damage = attacker_info.attacks[att_weapon].damage
-- Opponent resistance modifier
local att_multiplier = defender_info.resist_mod[attacker_info.attacks[att_weapon].type] or 1
-- TOD modifier
att_multiplier = att_multiplier * AH.get_unit_time_of_day_bonus(attacker_info.alignment, att_lawful_bonus)
-- Now do all this for the defender, if def_weapon ~= 0
local def_damage, def_multiplier = 0, 1.
if (def_weapon ~= 0) then
-- Defender base damage
def_damage = defender_info.attacks[def_weapon].damage
-- Opponent resistance modifier
def_multiplier = attacker_info.resist_mod[defender_info.attacks[def_weapon].type] or 1
-- TOD modifier
def_multiplier = def_multiplier * AH.get_unit_time_of_day_bonus(defender_info.alignment, def_lawful_bonus)
end
-- Take 'charge' into account
if attacker_info.attacks[att_weapon].charge then
att_damage = att_damage * 2
def_damage = def_damage * 2
end
-- Rounding of .5 values is done differently depending on whether the
-- multiplier is greater or smaller than 1
if (att_multiplier > 1) then
att_damage = mathx.round(att_damage * att_multiplier - 0.001)
else
att_damage = mathx.round(att_damage * att_multiplier + 0.001)
end
if (def_weapon ~= 0) then
if (def_multiplier > 1) then
def_damage = mathx.round(def_damage * def_multiplier - 0.001)
else
def_damage = mathx.round(def_damage * def_multiplier + 0.001)
end
end
if cache then
cache[cind] = {
att_damage = att_damage,
def_damage = def_damage,
att_attack = attacker_info.attacks[att_weapon],
def_attack = defender_info.attacks[def_weapon]
}
end
return att_damage, def_damage, attacker_info.attacks[att_weapon], defender_info.attacks[def_weapon]
end
function battle_calcs.best_weapons(attacker, defender, dst, cache)
-- Return the number (index) of the best weapons for @attacker and @defender
-- @dst: attack location, to take terrain time of day, illumination etc. into account
-- For the defender, the current location is assumed
-- Ideally, we would do a full attack_rating here for all combinations,
-- but that would take too long. So we simply define the best weapons
-- as those that has the biggest difference between
-- damage done and damage received (the latter divided by 2)
-- Returns 0 if defender does not have a weapon for this range
--
-- 'cache' can be given to cache best weapons
-- Set up a cache index. We use id+max_hitpoints+side for each unit, since the
-- unit can level up.
-- Also need to add the weapons and lawful_bonus values for each unit
local att_lawful_bonus = wesnoth.schedule.get_illumination(dst).lawful_bonus
local def_lawful_bonus = wesnoth.schedule.get_illumination(defender).lawful_bonus
local cind = 'BW-' .. attacker.id .. attacker.max_hitpoints .. attacker.side
cind = cind .. 'x' .. defender.id .. defender.max_hitpoints .. defender.side
cind = cind .. '-' .. att_lawful_bonus .. 'x' .. def_lawful_bonus
-- If cache for this unit exists, return it
if cache and cache[cind] then
return cache[cind].best_att_weapon, cache[cind].best_def_weapon
end
local attacker_info = battle_calcs.unit_attack_info(attacker, cache)
local defender_info = battle_calcs.unit_attack_info(defender, cache)
-- Best attacker weapon
local max_rating, best_att_weapon, best_def_weapon = - math.huge, 0, 0
for att_weapon_number,att_weapon in ipairs(attacker_info.attacks) do
local att_damage = battle_calcs.strike_damage(attacker, defender, att_weapon_number, 0, { dst[1], dst[2] }, cache)
local max_def_rating, tmp_best_def_weapon = - math.huge, 0
for def_weapon_number,def_weapon in ipairs(defender_info.attacks) do
if (def_weapon.range == att_weapon.range) then
local def_damage = battle_calcs.strike_damage(defender, attacker, def_weapon_number, 0, { defender.x, defender.y }, cache)
local def_rating = def_damage * def_weapon.number
if (def_rating > max_def_rating) then
max_def_rating, tmp_best_def_weapon = def_rating, def_weapon_number
end
end
end
local rating = att_damage * att_weapon.number
if (max_def_rating > - math.huge) then rating = rating - max_def_rating / 2. end
if (rating > max_rating) then
max_rating, best_att_weapon, best_def_weapon = rating, att_weapon_number, tmp_best_def_weapon
end
end
if cache then
cache[cind] = { best_att_weapon = best_att_weapon, best_def_weapon = best_def_weapon }
end
return best_att_weapon, best_def_weapon
end
function battle_calcs.add_next_strike(cfg, arr, n_att, n_def, att_strike, hit_miss_counts, hit_miss_str)
-- Recursive function that sets up the sequences of strikes (misses and hits)
-- Each call corresponds to one strike of one of the combattants and can be
-- either miss (value 0) or hit (1)
--
-- Inputs:
-- - @cfg: config table with sub-tables att/def for the attacker/defender with the following fields:
-- - strikes: total number of strikes
-- - max_hits: maximum number of hits the unit can survive
-- - firststrike: set to true if attack has firststrike special
-- - @arr: an empty array that will hold the output table
-- - Other parameters of for recursion purposes only and are initialized below
-- On the first call of this function, initialize variables
-- Counts for hits/misses by both units:
-- - Indices 1 & 2: hit/miss for attacker
-- - Indices 3 & 4: hit/miss for defender
hit_miss_counts = hit_miss_counts or { 0, 0, 0, 0 }
hit_miss_str = hit_miss_str or '' -- string with the hit/miss sequence; for visualization only
-- Strike counts
-- - n_att/n_def = number of strikes taken by attacker/defender
-- - att_strike: if true, it's the attacker's turn, otherwise it's the defender's turn
if (not n_att) then
if cfg.def.firststrike and (not cfg.att.firststrike) then
n_att = 0
n_def = 1
att_strike = false
else
n_att = 1
n_def = 0
att_strike = true
end
else
if att_strike then
if (n_def < cfg.def.strikes) then
n_def = n_def + 1
att_strike = false
else
n_att = n_att + 1
end
else
if (n_att < cfg.att.strikes) then
n_att = n_att + 1
att_strike = true
else
n_def = n_def + 1
end
end
end
-- Create both a hit and a miss
for i = 0,1 do -- 0:miss, 1: hit
-- hit/miss counts and string for this call
local tmp_hmc = AH.table_copy(hit_miss_counts)
local tmp_hmstr = ''
-- Flag whether the opponent was killed by this strike
local killed_opp = false -- Defaults to falso
if att_strike then
tmp_hmstr = hit_miss_str .. i -- attacker hit/miss in string: 0 or 1
tmp_hmc[i+1] = tmp_hmc[i+1] + 1 -- Increment hit/miss counts
-- Set variable if opponent was killed:
if (tmp_hmc[2] > cfg.def.max_hits) then killed_opp = true end
-- Even values of n are strikes by the defender
else
tmp_hmstr = hit_miss_str .. (i+2) -- defender hit/miss in string: 2 or 3
tmp_hmc[i+3] = tmp_hmc[i+3] + 1 -- Increment hit/miss counts
-- Set variable if opponent was killed:
if (tmp_hmc[4] > cfg.att.max_hits) then killed_opp = true end
end
-- If we've reached the total number of strikes, add this hit/miss combination to table,
-- but only if the opponent wasn't killed, as that would end the battle
if (n_att + n_def < cfg.att.strikes + cfg.def.strikes) and (not killed_opp) then
battle_calcs.add_next_strike(cfg, arr, n_att, n_def, att_strike, tmp_hmc, tmp_hmstr)
-- Otherwise, call the next recursion level
else
table.insert(arr, { hit_miss_str = tmp_hmstr, hit_miss_counts = tmp_hmc })
end
end
end
function battle_calcs.battle_outcome_coefficients(cfg, cache)
-- Determine the coefficients needed to calculate the hitpoint probability distribution
-- of a given battle
-- Inputs:
-- - @cfg: config table with sub-tables att/def for the attacker/defender with the following fields:
-- - strikes: total number of strikes
-- - max_hits: maximum number of hits the unit can survive
-- - firststrike: whether the unit has firststrike weapon special on this attack
-- The result can be cached if variable 'cache' is given
--
-- Output: table with the coefficients needed to calculate the distribution for both attacker and defender
-- First index: number of hits landed on the defender. Each of those contains an array of
-- coefficient tables, of format:
-- { num = value, am = value, ah = value, dm = value, dh = value }
-- This gives one term in a sum of form:
-- num * ahp^ah * (1-ahp)^am * dhp^dh * (1-dhp)^dm
-- where ahp is the probability that the attacker will land a hit
-- and dhp is the same for the defender
-- Terms that have exponents of 0 are omitted
-- Set up the cache id
local cind = 'coeff-' .. cfg.att.strikes .. '-' .. cfg.att.max_hits
if cfg.att.firststrike then cind = cind .. 'fs' end
cind = cind .. 'x' .. cfg.def.strikes .. '-' .. cfg.def.max_hits
if cfg.def.firststrike then cind = cind .. 'fs' end
-- If cache for this unit exists, return it
if cache and cache[cind] then
return cache[cind].coeffs_att, cache[cind].coeffs_def
end
-- Get the hit/miss counts for the battle
local hit_miss_counts = {}
battle_calcs.add_next_strike(cfg, hit_miss_counts)
-- We first calculate the coefficients for the defender HP distribution
-- so this is sorted by the number of hits the attacker lands
-- 'counts' is an array 4 layers deep, where the indices are the number of misses/hits
-- are the indices in order attacker miss, attacker hit, defender miss, defender hit
-- This is so that they can be grouped by number of attacker hits/misses, for
-- subsequent simplification
-- The element value is number of times we get the given combination of hits/misses
local counts1 = {}
for _,count in ipairs(hit_miss_counts) do
local i1 = count.hit_miss_counts[1]
local i2 = count.hit_miss_counts[2]
local i3 = count.hit_miss_counts[3]
local i4 = count.hit_miss_counts[4]
if not counts1[i1] then counts1[i1] = {} end
if not counts1[i1][i2] then counts1[i1][i2] = {} end
if not counts1[i1][i2][i3] then counts1[i1][i2][i3] = {} end
counts1[i1][i2][i3][i4] = (counts1[i1][i2][i3][i4] or 0) + 1
end
local coeffs_def = {}
for am,v1 in pairs(counts1) do -- attacker miss count
for ah,v2 in pairs(v1) do -- attacker hit count
-- Set up the exponent coefficients for attacker hits/misses
local exp = {} -- Array for an individual set of coefficients
-- Only populate those indices that have exponents > 0
if (am > 0) then exp.am = am end
if (ah > 0) then exp.ah = ah end
-- We combine results by testing whether they produce the same sum
-- with two very different hit probabilities, hp1 = 0.6, hp2 = 0.137
-- This will only happen is the coefficients add up to multiples of 1
local sum1, sum2 = 0,0
local hp1, hp2 = 0.6, 0.137
for dm,v3 in pairs(v2) do -- defender miss count
for dh,num in pairs(v3) do -- defender hit count
sum1 = sum1 + num * hp1^dh * (1 - hp1)^dm
sum2 = sum2 + num * hp2^dh * (1 - hp2)^dm
end
end
-- Now, coefficients are set up for each value of total hits by attacker
-- This holds all the coefficients that need to be added to get the propability
-- of the defender receiving this number of hits
if (not coeffs_def[ah]) then coeffs_def[ah] = {} end
-- If sum1 and sum2 are equal, that means all the defender probs added up to 1, or
-- multiple thereof, which means the can all be combine in the calculation
if (math.abs(sum1 - sum2) < 1e-9) then
exp.num = sum1
table.insert(coeffs_def[ah], exp)
-- If not, the defender probs don't add up to something nice and all
-- need to be calculated one by one
else
for dm,v3 in pairs(v2) do -- defender miss count
for dh,num in pairs(v3) do -- defender hit count
local tmp_exp = AH.table_copy(exp)
tmp_exp.num = num
if (dm > 0) then tmp_exp.dm = dm end
if (dh > 0) then tmp_exp.dh = dh end
table.insert(coeffs_def[ah], tmp_exp)
end
end
end
end
end
-- Now we do the same for the HP distribution of the attacker,
-- which means everything needs to be sorted by defender hits
local counts2 = {}
for _,count in ipairs(hit_miss_counts) do
local i1 = count.hit_miss_counts[3] -- note that the order here is different from above
local i2 = count.hit_miss_counts[4]
local i3 = count.hit_miss_counts[1]
local i4 = count.hit_miss_counts[2]
if not counts2[i1] then counts2[i1] = {} end
if not counts2[i1][i2] then counts2[i1][i2] = {} end
if not counts2[i1][i2][i3] then counts2[i1][i2][i3] = {} end
counts2[i1][i2][i3][i4] = (counts2[i1][i2][i3][i4] or 0) + 1
end
local coeffs_att = {}
for dm,v1 in pairs(counts2) do -- defender miss count
for dh,v2 in pairs(v1) do -- defender hit count
-- Set up the exponent coefficients for attacker hits/misses
local exp = {} -- Array for an individual set of coefficients
-- Only populate those indices that have exponents > 0
if (dm > 0) then exp.dm = dm end
if (dh > 0) then exp.dh = dh end
-- We combine results by testing whether they produce the same sum
-- with two very different hit probabilities, hp1 = 0.6, hp2 = 0.137
-- This will only happen is the coefficients add up to multiples of 1
local sum1, sum2 = 0,0
local hp1, hp2 = 0.6, 0.137
for am,v3 in pairs(v2) do -- attacker miss count
for ah,num in pairs(v3) do -- attacker hit count
sum1 = sum1 + num * hp1^ah * (1 - hp1)^am
sum2 = sum2 + num * hp2^ah * (1 - hp2)^am
end
end
-- Now, coefficients are set up for each value of total hits by attacker
-- This holds all the coefficients that need to be added to get the propability
-- of the defender receiving this number of hits
if (not coeffs_att[dh]) then coeffs_att[dh] = {} end
-- If sum1 and sum2 are equal, that means all the defender probs added up to 1, or
-- multiple thereof, which means the can all be combine in the calculation
if (math.abs(sum1 - sum2) < 1e-9) then
exp.num = sum1
table.insert(coeffs_att[dh], exp)
-- If not, the defender probs don't add up to something nice and all
-- need to be calculated one by one
else
for am,v3 in pairs(v2) do -- defender miss count
for ah,num in pairs(v3) do -- defender hit count
local tmp_exp = AH.table_copy(exp)
tmp_exp.num = num
if (am > 0) then tmp_exp.am = am end
if (ah > 0) then tmp_exp.ah = ah end
table.insert(coeffs_att[dh], tmp_exp)
end
end
end
end
end
-- The probability for the number of hits with the most terms can be skipped
-- and 1-sum(other_terms) can be used instead. Set a flag for which term to skip
local max_number1, biggest_equation1 = 0, -1
for hits,v in pairs(coeffs_att) do
local number = 0
for _,c in pairs(v) do number = number + 1 end
if (number > max_number1) then
max_number1, biggest_equation1 = number, hits
end
end
coeffs_att[biggest_equation1].skip = true
local max_number2, biggest_equation2 = 0, -1
for hits,v in pairs(coeffs_def) do
local number = 0
for _,c in pairs(v) do number = number + 1 end
if (number > max_number2) then
max_number2, biggest_equation2 = number, hits
end
end
coeffs_def[biggest_equation2].skip = true
if cache then cache[cind] = { coeffs_att = coeffs_att, coeffs_def = coeffs_def } end
return coeffs_att, coeffs_def
end
function battle_calcs.print_coefficients()
-- Print out the set of coefficients for a given number of attacker and defender strikes
-- Also print numerical values for a given hit probability
-- This function is for debugging purposes only
-- Configure these values at will
local attacker_strikes, defender_strikes = 3, 3 -- number of strikes
local att_hit_prob, def_hit_prob = 0.8, 0.4 -- probability of landing a hit attacker/defender
local attacker_coeffs = true -- attacker coefficients if set to true, defender coefficients otherwise
local defender_firststrike, attacker_firststrike = true, false
-- Go through all combinations of maximum hits either attacker or defender can survive
-- Note how this has to be crossed between ahits and defender_strikes and vice versa
for ahits = defender_strikes,0,-1 do
for dhits = attacker_strikes,0,-1 do
-- Get the coefficients for this case
local cfg = {
att = { strikes = attacker_strikes, max_hits = ahits, firststrike = attacker_firststrike },
def = { strikes = defender_strikes, max_hits = dhits, firststrike = defender_firststrike }
}
local coeffs, dummy = {}, {}
if attacker_coeffs then
coeffs = battle_calcs.battle_outcome_coefficients(cfg)
else
dummy, coeffs = battle_calcs.battle_outcome_coefficients(cfg)
end
std_print()
std_print('Attacker: ' .. cfg.att.strikes .. ' strikes, can survive ' .. cfg.att.max_hits .. ' hits')
std_print('Defender: ' .. cfg.def.strikes .. ' strikes, can survive ' .. cfg.def.max_hits .. ' hits')
std_print('Chance of hits on defender: ')
-- The first indices of coeffs are the possible number of hits the attacker can land on the defender
for hits = 0,#coeffs do
local hit_prob = 0. -- probability for this number of hits
local str = '' -- output string
local combs = coeffs[hits] -- the combinations of coefficients to be evaluated
for i,exp in ipairs(combs) do -- exp: exponents (and factor) for a set
local prob = exp.num -- probability for this set
str = str .. exp.num
if exp.am then
prob = prob * (1 - att_hit_prob) ^ exp.am
str = str .. ' pma^' .. exp.am
end
if exp.ah then
prob = prob * att_hit_prob ^ exp.ah
str = str .. ' pha^' .. exp.ah
end
if exp.dm then
prob = prob * (1 - def_hit_prob) ^ exp.dm
str = str .. ' pmd^' .. exp.dm
end
if exp.dh then
prob = prob * def_hit_prob ^ exp.dh
str = str .. ' phd^' .. exp.dh
end
hit_prob = hit_prob + prob -- total probabilty for this number of hits landed
if (i ~= #combs) then str = str .. ' + ' end
end
local skip_str = ''
if combs.skip then skip_str = ' (skip)' end
std_print(hits .. skip_str .. ': ' .. str)
std_print(' = ' .. hit_prob)
end
end
end
end
function battle_calcs.hp_distribution(coeffs, att_hit_prob, def_hit_prob, starting_hp, damage, opp_attack)
-- Multiply out the coefficients from battle_calcs.battle_outcome_coefficients()
-- For a given attacker and defender hit/miss probability
-- Also needed: the starting HP for the unit and the damage done by the opponent
-- and the opponent attack information @opp_attack
local stats = { hp_chance = {}, average_hp = 0 }
local skip_hp, skip_prob = -1, 1
for hits = 0,#coeffs do
local hp = starting_hp - hits * damage
if (hp < 0) then hp = 0 end
-- Calculation of the outcome with the most terms can be skipped
if coeffs[hits].skip then
skip_hp = hp
else
local hp_prob = 0. -- probability for this number of hits
for _,exp in ipairs(coeffs[hits]) do -- exp: exponents (and factor) for a set
local prob = exp.num -- probability for this set
if exp.am then prob = prob * (1 - att_hit_prob) ^ exp.am end
if exp.ah then prob = prob * att_hit_prob ^ exp.ah end
if exp.dm then prob = prob * (1 - def_hit_prob) ^ exp.dm end
if exp.dh then prob = prob * def_hit_prob ^ exp.dh end
hp_prob = hp_prob + prob -- total probabilty for this number of hits landed
end
stats.hp_chance[hp] = hp_prob
stats.average_hp = stats.average_hp + hp * hp_prob
-- Also subtract this probability from the total prob. (=1), to get prob. of skipped outcome
skip_prob = skip_prob - hp_prob
end
end
-- Add in the outcome that was skipped
stats.hp_chance[skip_hp] = skip_prob
stats.average_hp = stats.average_hp + skip_hp * skip_prob
-- And always set hp_chance[0] since it is of such importance in the analysis
stats.hp_chance[0] = stats.hp_chance[0] or 0
-- Add poison probability
if opp_attack and opp_attack.poison then
stats.poisoned = 1. - stats.hp_chance[starting_hp]
else
stats.poisoned = 0
end
-- Add slow probability
if opp_attack and opp_attack.slow then
stats.slowed = 1. - stats.hp_chance[starting_hp]
else
stats.slowed = 0
end
return stats
end
function battle_calcs.battle_outcome(attacker, defender, cfg, cache)
-- Calculate the stats of a combat by @attacker vs. @defender
-- @cfg: optional input parameters
-- - att_weapon/def_weapon: attacker/defender weapon number
-- if not given, get "best" weapon (Note: both must be given, or they will both be determined)
-- - dst: { x, y }: the attack location; defaults to { attacker.x, attacker.y }
-- @cache: to be passed on to other functions. battle_outcome itself is not cached, too many factors enter
cfg = cfg or {}
local dst = cfg.dst or { attacker.x, attacker.y }
local att_weapon, def_weapon = 0, 0
if (not cfg.att_weapon) or (not cfg.def_weapon) then
att_weapon, def_weapon = battle_calcs.best_weapons(attacker, defender, dst, cache)
else
att_weapon, def_weapon = cfg.att_weapon, cfg.def_weapon
end
-- Collect all the information needed for the calculation
-- Strike damage and numbers
local att_damage, def_damage, att_attack, def_attack =
battle_calcs.strike_damage(attacker, defender, att_weapon, def_weapon, { dst[1], dst[2] }, cache)
-- Take swarm into account
local att_strikes, def_strikes = att_attack.number, 0
if (def_damage > 0) then
def_strikes = def_attack.number
end
if att_attack.swarm then
att_strikes = math.floor(att_strikes * attacker.hitpoints / attacker.max_hitpoints)
end
if def_attack and def_attack.swarm then
def_strikes = math.floor(def_strikes * defender.hitpoints / defender.max_hitpoints)
end
-- At around 15 combined strikes, wesnoth.simulate_combat becomes faster
if (att_strikes + def_strikes > 14) then
if (def_weapon == 0) then def_weapon = nil end
if (att_weapon == 0) then
return wesnoth.simulate_combat(attacker, defender, def_weapon)
else
return wesnoth.simulate_combat(attacker, att_weapon, defender, def_weapon)
end
end
-- Maximum number of hits that either unit can survive
local att_max_hits = math.floor((attacker.hitpoints - 1) / def_damage)
if (att_max_hits > def_strikes) then att_max_hits = def_strikes end
local def_max_hits = math.floor((defender.hitpoints - 1) / att_damage)
if (def_max_hits > att_strikes) then def_max_hits = att_strikes end
-- Probability of landing a hit
local att_hit_prob = 1 - defender:defense_on(wesnoth.current.map[defender]) / 100.
local def_hit_prob = 1 - attacker:defense_on(wesnoth.current.map[dst]) / 100.
-- Magical: attack and defense, and under all circumstances
if att_attack.magical then att_hit_prob = 0.7 end
if def_attack and def_attack.magical then def_hit_prob = 0.7 end
-- Marksman: attack only, and only if terrain defense is less
if att_attack.marksman and (att_hit_prob < 0.6) then
att_hit_prob = 0.6
end
-- Get the coefficients for this kind of combat
local def_firstrike = false
if def_attack and def_attack.firststrike then def_firstrike = true end
local att_def_cfg = {
att = { strikes = att_strikes, max_hits = att_max_hits, firststrike = att_attack.firststrike },
def = { strikes = def_strikes, max_hits = def_max_hits, firststrike = def_firstrike }
}
local att_coeffs, def_coeffs = battle_calcs.battle_outcome_coefficients(att_def_cfg, cache)
-- And multiply out the factors
-- Note that att_hit_prob, def_hit_prob need to be in that order for both calls
local att_stats = battle_calcs.hp_distribution(att_coeffs, att_hit_prob, def_hit_prob, attacker.hitpoints, def_damage, def_attack)
local def_stats = battle_calcs.hp_distribution(def_coeffs, att_hit_prob, def_hit_prob, defender.hitpoints, att_damage, att_attack)
return att_stats, def_stats
end
function battle_calcs.simulate_combat_fake()
-- A function to return a fake simulate_combat result
-- Debug function to test how long simulate_combat takes
-- It doesn't need any arguments -> can be called with the arguments of other simulate_combat functions
local att_stats, def_stats = { hp_chance = {} }, { hp_chance = {} }
att_stats.hp_chance[0] = 0
att_stats.hp_chance[21], att_stats.hp_chance[23], att_stats.hp_chance[25], att_stats.hp_chance[27] = 0.125, 0.375, 0.375, 0.125
att_stats.poisoned, att_stats.slowed, att_stats.average_hp = 0.875, 0, 24
def_stats.hp_chance[0], def_stats.hp_chance[2], def_stats.hp_chance[10] = 0.09, 0.42, 0.49
def_stats.poisoned, def_stats.slowed, def_stats.average_hp = 0, 0, 1.74
return att_stats, def_stats
end
function battle_calcs.simulate_combat_loc(attacker, dst, defender, weapon)
-- Get simulate_combat results for unit @attacker attacking unit @defender
-- when on terrain of same type as that at @dst, which is of form { x, y }
-- If @weapon is set, use that weapon (Lua index starting at 1), otherwise use best weapon
local attacker_dst = attacker:clone()
attacker_dst.x, attacker_dst.y = dst[1], dst[2]
if weapon then
return wesnoth.simulate_combat(attacker_dst, weapon, defender)
else
return wesnoth.simulate_combat(attacker_dst, defender)
end
end
function battle_calcs.attack_rating(attacker, defender, dst, cfg, cache)
-- Returns a common (but configurable) rating for attacks
-- Inputs:
-- @attacker: attacker unit
-- @defender: defender unit
-- @dst: the attack location in form { x, y }
-- @cfg: table of optional inputs and configurable rating parameters
-- Optional inputs:
-- - att_stats, def_stats: if given, use these stats, otherwise calculate them here
-- Note: these are calculated in combination, that is they either both need to be passed or both be omitted
-- - att_weapon/def_weapon: the attacker/defender weapon to be used if calculating battle stats here
-- This parameter is meaningless (unused) if att_stats/def_stats are passed
-- Defaults to weapon that does most damage to the opponent
-- Note: as with the stats, they either both need to be passed or both be omitted
-- @cache: cache table to be passed to battle_calcs.battle_outcome
--
-- Returns:
-- - Overall rating for the attack or attack combo
-- - Defender rating: not additive for attack combos; needs to be calculated for the
-- defender stats of the last attack in a combo (that works for everything except
-- the rating whether the defender is about to level in the attack combo)
-- - Attacker rating: this one is split up into two terms:
-- - a term that is additive for individual attacks in a combo
-- - a term that needs to be average for the individual attacks in a combo
-- - att_stats, def_stats: useful if they were calculated here, rather than passed down
cfg = cfg or {}
-- Set up the config parameters for the rating
local enemy_leader_weight = cfg.enemy_leader_weight or 5.
local defender_starting_damage_weight = cfg.defender_starting_damage_weight or 0.33
local xp_weight = cfg.xp_weight or 0.25
local level_weight = cfg.level_weight or 1.0
local defender_level_weight = cfg.defender_level_weight or 1.0
local distance_leader_weight = cfg.distance_leader_weight or 0.002
local defense_weight = cfg.defense_weight or 0.1
local occupied_hex_penalty = cfg.occupied_hex_penalty or -0.001
local own_value_weight = cfg.own_value_weight or 1.0
-- Get att_stats, def_stats
-- If they are passed in cfg, use those
local att_stats, def_stats = {}, {}
if (not cfg.att_stats) or (not cfg.def_stats) then
-- If cfg specifies the weapons use those, otherwise use "best" weapons
-- In the latter case, cfg.???_weapon will be nil, which will be passed on
local battle_cfg = { att_weapon = cfg.att_weapon, def_weapon = cfg.def_weapon, dst = dst }
att_stats,def_stats = battle_calcs.battle_outcome(attacker, defender, battle_cfg, cache)
else
att_stats, def_stats = cfg.att_stats, cfg.def_stats
end
-- We also need the leader (well, the location at least)
-- because if there's no other difference, prefer location _between_ the leader and the defender
local leader = wesnoth.units.find_on_map { side = attacker.side, canrecruit = 'yes' }[1]
------ All the attacker contributions: ------
-- Add up rating for the attacking unit
-- We add this up in units of fraction of max_hitpoints
-- It is multiplied by unit cost later, to get a gold equivalent value
-- Average damage to unit is negative rating
local att_damage = attacker.hitpoints - att_stats.average_hp
-- Count poisoned as additional damage done by poison times probability of being poisoned
if (att_stats.poisoned ~= 0) then
att_damage = att_damage + wesnoth.game_config.poison_amount * (att_stats.poisoned - att_stats.hp_chance[0])
end
-- Count slowed as additional 6 HP damage times probability of being slowed
if (att_stats.slowed ~= 0) then
att_damage = att_damage + 6 * (att_stats.slowed - att_stats.hp_chance[0])
end
local map = wesnoth.current.map
-- If attack is from a healing location, count that as slightly more than the healing amount
att_damage = att_damage - 1.25 * wesnoth.terrain_types[map[dst]].healing
-- Equivalently, if attack is adjacent to an unoccupied healing location, that's bad
for xa,ya in wesnoth.current.map:iter_adjacent(dst) do
local healing = wesnoth.terrain_types[map[{xa, ya}]].healing
if (healing > 0) and (not wesnoth.units.get(xa, ya)) then
att_damage = att_damage + 1.25 * healing
end
end
if (att_damage < 0) then att_damage = 0 end
-- Fraction damage (= fractional value of the unit)
local att_value_fraction = - att_damage / attacker.max_hitpoints
-- Additional, subtract the chance to die, in order to (de)emphasize units that might die
att_value_fraction = att_value_fraction - att_stats.hp_chance[0]
-- In addition, potentially leveling up in this attack is a huge bonus,
-- proportional to the chance of it happening and the chance of not dying itself
local level_bonus = 0.
local defender_level = defender.level
if (attacker.max_experience - attacker.experience <= defender_level * wesnoth.game_config.combat_experience) then
level_bonus = 1. - att_stats.hp_chance[0]
else
if (defender_level == 0) then defender_level = 0.5 end
if (attacker.max_experience - attacker.experience <= defender_level * wesnoth.game_config.kill_experience) then
level_bonus = (1. - att_stats.hp_chance[0]) * def_stats.hp_chance[0]
end
end
att_value_fraction = att_value_fraction + level_bonus * level_weight
-- Now convert this into gold-equivalent value
local attacker_value = attacker.cost
-- Being closer to leveling is good (this makes AI prefer units with lots of XP)
local att_xp_bonus = attacker.experience / attacker.max_experience
attacker_value = attacker_value * (1. + att_xp_bonus * xp_weight)
local attacker_rating = att_value_fraction * attacker_value
------ Now (most of) the same for the defender ------
-- Average damage to defender is positive rating
local def_damage = defender.hitpoints - def_stats.average_hp
-- Count poisoned as additional damage done by poison times probability of being poisoned
if (def_stats.poisoned ~= 0) then
def_damage = def_damage + wesnoth.game_config.poison_amount * (def_stats.poisoned - def_stats.hp_chance[0])
end
-- Count slowed as additional 6 HP damage times probability of being slowed
if (def_stats.slowed ~= 0) then
def_damage = def_damage + 6 * (def_stats.slowed - def_stats.hp_chance[0])
end
-- If defender is on a healing location, count that as slightly more than the healing amount
def_damage = def_damage - 1.25 * wesnoth.terrain_types[map[defender]].healing
if (def_damage < 0) then damage = 0. end
-- Fraction damage (= fractional value of the unit)
local def_value_fraction = def_damage / defender.max_hitpoints
-- Additional, add the chance to kill, in order to emphasize enemies we might be able to kill
def_value_fraction = def_value_fraction + def_stats.hp_chance[0]
-- In addition, the defender potentially leveling up in this attack is a huge penalty,
-- proportional to the chance of it happening and the chance of not dying itself
local defender_level_penalty = 0.
local attacker_level = attacker.level
if (defender.max_experience - defender.experience <= attacker_level * wesnoth.game_config.combat_experience) then
defender_level_penalty = 1. - def_stats.hp_chance[0]
else
if (attacker_level == 0) then attacker_level = 0.5 end
if (defender.max_experience - defender.experience <= attacker_level * wesnoth.game_config.kill_experience) then
defender_level_penalty = (1. - def_stats.hp_chance[0]) * att_stats.hp_chance[0]
end
end
def_value_fraction = def_value_fraction - defender_level_penalty * defender_level_weight
-- Now convert this into gold-equivalent value
local defender_value = defender.cost
-- If this is the enemy leader, make damage to it much more important
if defender.canrecruit then
defender_value = defender_value * enemy_leader_weight
end
-- And prefer to attack already damaged enemies
local defender_starting_damage_fraction = (defender.max_hitpoints - defender.hitpoints) / defender.max_hitpoints
defender_value = defender_value * (1. + defender_starting_damage_fraction * defender_starting_damage_weight)
-- Being closer to leveling is good, we want to get rid of those enemies first
local def_xp_bonus = defender.experience / defender.max_experience
defender_value = defender_value * (1. + def_xp_bonus * xp_weight)
-- If defender is on a village, add a bonus rating (we want to get rid of those preferentially)
-- So yes, this is positive, even though it's a plus for the defender
-- Note: defenders on healing locations also got a negative damage rating above (these don't exactly cancel each other though)
if wesnoth.terrain_types[map[defender]].village then
defender_value = defender_value * (1. + 10. / attacker.max_hitpoints)
end
-- We also add a few contributions that are not directly attack/damage dependent
-- These are added to the defender rating for two reasons:
-- 1. Defender rating is positive (and thus contributions can be made positive)
-- 2. It is then independent of value of aggression (cfg.own_value_weight)
--
-- These are kept small though, so they mostly only serve as tie breakers
-- And yes, they might bring the overall rating from slightly negative to slightly positive
-- or vice versa, but as that is only approximate anyway, we keep it this way for simplicity
-- We don't need a bonus for good terrain for the attacker, as that is covered in the damage calculation
-- However, we add a small bonus for good terrain defense of the _defender_ on the _attack_ hex
-- This is in order to take good terrain away from defender on next move, all else being equal
local defender_defense = - (100 - defender:defense_on(map[dst])) / 100.
defender_value = defender_value + defender_defense * defense_weight
-- Get a very small bonus for hexes in between defender and AI leader
-- 'relative_distances' is larger for attack hexes closer to the side leader (possible values: -1 .. 1)
if leader then
local relative_distances =
M.distance_between(defender.x, defender.y, leader.x, leader.y)
- M.distance_between(dst[1], dst[2], leader.x, leader.y)
defender_value = defender_value + relative_distances * distance_leader_weight
end
-- Add a very small penalty for attack hexes occupied by other units
-- Note: it must be checked previously that the unit on the hex can move away
if (dst[1] ~= attacker.x) or (dst[2] ~= attacker.y) then
if wesnoth.units.get(dst[1], dst[2]) then
defender_value = defender_value + occupied_hex_penalty
end
end
local defender_rating = def_value_fraction * defender_value
-- Finally apply factor of own unit weight to defender unit weight
attacker_rating = attacker_rating * own_value_weight
local rating = defender_rating + attacker_rating
return rating, defender_rating, attacker_rating, att_stats, def_stats
end
function battle_calcs.attack_combo_stats(tmp_attackers, tmp_dsts, defender, cache, cache_this_move)
-- Calculate attack combination outcomes using
-- @tmp_attackers: array of attacker units (this is done so that
-- the units need not be found here, as likely doing it in the
-- calling function is more efficient (because of repetition)
-- @tmp_dsts: array of the hexes (format { x, y }) from which the attackers attack
-- must be in same order as @attackers
-- @defender: the unit being attacked
-- @cache: the cache table to be passed through to other battle_calcs functions
-- attack_combo_stats itself is not cached, except for in cache_this_move below
-- @cache_this_move: an optional table of pre-calculated attack outcomes
-- - This is different from the other cache tables used in this file
-- - This table may only persist for this move (move, not turn !!!), as otherwise too many things change
--
-- Return values:
-- - The rating for this attack combination calculated from battle_calcs.attack_rating() results
-- - The sorted attackers and dsts arrays
-- - att_stats: an array of stats for each attacker, in the same order as 'attackers'
-- - defender combo stats: one set of stats containing the defender stats after the attack combination
-- - def_stats: an array of defender stats for each individual attack, in the same order as 'attackers'
cache_this_move = cache_this_move or {}
-- We first simulate and rate the individual attacks
local ratings, tmp_attacker_ratings = {}, {}
local tmp_att_stats, tmp_def_stats = {}, {}
local defender_ind = defender.x * 1000 + defender.y
for i,attacker in ipairs(tmp_attackers) do
-- Initialize or use the 'cache_this_move' table
local att_ind = attacker.x * 1000 + attacker.y
local dst_ind = tmp_dsts[i][1] * 1000 + tmp_dsts[i][2]
if (not cache_this_move[defender_ind]) then cache_this_move[defender_ind] = {} end
if (not cache_this_move[defender_ind][att_ind]) then cache_this_move[defender_ind][att_ind] = {} end
if (not cache_this_move[defender_ind][att_ind][dst_ind]) then
-- Get the base rating
local base_rating, def_rating, att_rating, att_stats, def_stats =
battle_calcs.attack_rating(attacker, defender, tmp_dsts[i], {}, cache )
tmp_attacker_ratings[i] = att_rating
tmp_att_stats[i], tmp_def_stats[i] = att_stats, def_stats
-- But for combos, also want units with highest attack outcome uncertainties to go early
-- So that we can change our mind in case of unfavorable outcome
--local outcome_variance = 0
--local av = tmp_def_stats[i].average_hp
--local n_outcomes = 0
--for hp,p in pairs(tmp_def_stats[i].hp_chance) do
-- if (p > 0) then
-- local dhp_norm = (hp - av) / defender.max_hitpoints * defender.cost
-- local dvar = p * dhp_norm^2
-- outcome_variance = outcome_variance + dvar
-- n_outcomes = n_outcomes + 1
-- end
--end
--outcome_variance = outcome_variance / n_outcomes
-- Note that this is a variance, not a standard deviations (as in, it's squared),
-- so it does not matter much for low-variance attacks, but takes on large values for
-- high variance attacks. I think that is what we want.
local rating = base_rating --+ outcome_variance
-- If attacker has attack with 'slow' special, it should always go first
-- Almost, bonus should not be quite as high as a really high CTK
-- This isn't quite true in reality, but can be refined later
if attacker:find_attack { special_id = "slow" } then
rating = rating + defender.cost / 2.
end
ratings[i] = { i, rating, base_rating, def_rating, att_rating }
-- Now add this attack to the cache_this_move table, so that next time around, we don't have to do this again
cache_this_move[defender_ind][att_ind][dst_ind] = {
rating = { -1, rating, base_rating, def_rating, att_rating }, -- Cannot use { i, rating, ... } here, as 'i' might be different next time
attacker_ratings = tmp_attacker_ratings[i],
att_stats = tmp_att_stats[i],
def_stats = tmp_def_stats[i]
}
else
local tmp_rating = cache_this_move[defender_ind][att_ind][dst_ind].rating
tmp_rating[1] = i
ratings[i] = tmp_rating
tmp_attacker_ratings[i] = cache_this_move[defender_ind][att_ind][dst_ind].attacker_ratings
tmp_att_stats[i] = cache_this_move[defender_ind][att_ind][dst_ind].att_stats
tmp_def_stats[i] = cache_this_move[defender_ind][att_ind][dst_ind].def_stats
end
end
-- Now sort all the arrays based on this rating
-- This will give the order in which the individual attacks are executed
table.sort(ratings, function(a, b) return a[2] > b[2] end)
-- Reorder attackers, dsts in this order
local attackers, dsts, att_stats, def_stats, attacker_ratings = {}, {}, {}, {}, {}
for i,rating in ipairs(ratings) do
attackers[i], dsts[i] = tmp_attackers[rating[1]], tmp_dsts[rating[1]]
end
-- Only keep the stats/ratings for the first attacker, the rest needs to be recalculated
att_stats[1], def_stats[1] = tmp_att_stats[ratings[1][1]], tmp_def_stats[ratings[1][1]]
attacker_ratings[1] = tmp_attacker_ratings[ratings[1][1]]
tmp_attackers, tmp_dsts, tmp_att_stats, tmp_def_stats, tmp_attacker_ratings = nil, nil, nil, nil, nil
-- Then we go through all the other attacks and calculate the outcomes
-- based on all the possible outcomes of the previous attacks
for i = 2,#attackers do
att_stats[i] = { hp_chance = {} }
def_stats[i] = { hp_chance = {} }
local dst_ind = dsts[i][1] * 1000 + dsts[i][2]
for hp1,prob1 in pairs(def_stats[i-1].hp_chance) do -- Note: need pairs(), not ipairs() !!
if (hp1 == 0) then
att_stats[i].hp_chance[attackers[i].hitpoints] =
(att_stats[i].hp_chance[attackers[i].hitpoints] or 0) + prob1
def_stats[i].hp_chance[0] = (def_stats[i].hp_chance[0] or 0) + prob1
else
local org_hp = defender.hitpoints
defender.hitpoints = hp1
local ast, dst
local att_ind_i = attackers[i].x * 1000 + attackers[i].y
if (not cache_this_move[defender_ind][att_ind_i][dst_ind][hp1]) then
ast, dst = battle_calcs.battle_outcome(attackers[i], defender, { dst = dsts[i] } , cache)
cache_this_move[defender_ind][att_ind_i][dst_ind][hp1] = { ast = ast, dst = dst }
else
ast = cache_this_move[defender_ind][att_ind_i][dst_ind][hp1].ast
dst = cache_this_move[defender_ind][att_ind_i][dst_ind][hp1].dst
end
defender.hitpoints = org_hp
for hp2,prob2 in pairs(ast.hp_chance) do
att_stats[i].hp_chance[hp2] = (att_stats[i].hp_chance[hp2] or 0) + prob1 * prob2
end
for hp2,prob2 in pairs(dst.hp_chance) do
def_stats[i].hp_chance[hp2] = (def_stats[i].hp_chance[hp2] or 0) + prob1 * prob2
end
-- Also do poisoned, slowed
if (not att_stats[i].poisoned) then
att_stats[i].poisoned = ast.poisoned
att_stats[i].slowed = ast.slowed
def_stats[i].poisoned = 1. - (1. - dst.poisoned) * (1. - def_stats[i-1].poisoned)
def_stats[i].slowed = 1. - (1. - dst.slowed) * (1. - def_stats[i-1].slowed)
end
end
end
-- Get the average HP
local att_av_hp = 0
for hp,prob in pairs(att_stats[i].hp_chance) do att_av_hp = att_av_hp + hp * prob end
att_stats[i].average_hp = att_av_hp
local def_av_hp = 0
for hp,prob in pairs(def_stats[i].hp_chance) do def_av_hp = def_av_hp + hp * prob end
def_stats[i].average_hp = def_av_hp
end
-- Get the total rating for this attack combo:
-- = sum of all the attacker ratings and the defender rating with the final def_stats
-- Rating for first attack exists already
local def_rating = ratings[1][4]
local att_rating = ratings[1][5]
-- The others need to be calculated with the new stats
for i = 2,#attackers do
local cfg = { att_stats = att_stats[i], def_stats = def_stats[i] }
local r, dr, ar = battle_calcs.attack_rating(attackers[i], defender, dsts[i], cfg, cache)
def_rating = dr
att_rating = att_rating + ar
end
local rating = def_rating + att_rating
return rating, attackers, dsts, att_stats, def_stats[#attackers], def_stats
end
function battle_calcs.get_attack_map_unit(unit, cfg)
-- Get all hexes that @unit can attack
-- Return value is a location set, where the values are tables, containing
-- - units: the number of units (always 1 for this function)
-- - hitpoints: the combined hitpoints of the units
-- - srcs: an array containing the positions of the units
-- @cfg: table with config parameters
-- max_moves: if set use max_moves for units (this setting is always used for units on other sides)
cfg = cfg or {}
-- 'moves' can be either "current" or "max"
-- For unit on current side: use "current" by default, or override by cfg.moves
local max_moves = cfg.max_moves
-- For unit on any other side, only max_moves=true makes sense
if (unit.side ~= wesnoth.current.side) then max_moves = true end
local old_moves = unit.moves
if max_moves then unit.moves = unit.max_moves end
local reach = {}
reach.units = LS.create()
reach.hitpoints = LS.create()
-- Also for units on the other side, take all units on this side with
-- MP left off the map (for enemy pathfinding)
local units_MP = {}
if (unit.side ~= wesnoth.current.side) then
local all_units = wesnoth.units.find_on_map { side = wesnoth.current.side }
for _,a_unit in ipairs(all_units) do
if (a_unit.moves > 0) then
table.insert(units_MP, a_unit)
a_unit:extract()
end
end
end
-- Find hexes the unit can reach
local initial_reach = wesnoth.paths.find_reach(unit, cfg)
-- Put the units back out there
if (unit.side ~= wesnoth.current.side) then
for _,uMP in ipairs(units_MP) do uMP:to_map() end
end
for _,loc in ipairs(initial_reach) do
reach.units:insert(loc[1], loc[2], 1)
reach.hitpoints:insert(loc[1], loc[2], unit.hitpoints)
for xa,ya in wesnoth.current.map:iter_adjacent(loc) do
reach.units:insert(xa, ya, 1)
reach.hitpoints:insert(xa, ya, unit.hitpoints)
end
end
if max_moves then unit.moves = old_moves end
return reach
end
function battle_calcs.get_attack_map(units, cfg)
-- Get all hexes that @units can attack. This is really just a wrapper
-- function for battle_calcs.get_attack_map_unit()
-- Return value is a location set, where the values are tables, containing
-- - units: the number of units (always 1 for this function)
-- - hitpoints: the combined hitpoints of the units
-- - srcs: an array containing the positions of the units
-- @cfg: table with config parameters
-- max_moves: if set use max_moves for units (this setting is always used for units on other sides)
local attack_map1 = {}
attack_map1.units = LS.create()
attack_map1.hitpoints = LS.create()
for _,unit in ipairs(units) do
local attack_map2 = battle_calcs.get_attack_map_unit(unit, cfg)
attack_map1.units:union_merge(attack_map2.units, function(x, y, v1, v2)
return (v1 or 0) + v2
end)
attack_map1.hitpoints:union_merge(attack_map2.hitpoints, function(x, y, v1, v2)
return (v1 or 0) + v2
end)
end
return attack_map1
end
function battle_calcs.relative_damage_map(units, enemies, cache)
-- Returns a location set map containing the relative damage of
-- @units vs. @enemies on the part of the map that the combined units
-- can reach. The damage is calculated as the sum of defender_rating
-- from attack_rating(), and thus (roughly) in gold units.
-- Also returns the same maps for the own and enemy units only
-- (with the enemy_damage_map having positive sign, while in the
-- overall damage map it is subtracted)
-- Get the attack maps for each unit in 'units' and 'enemies'
local my_attack_maps, enemy_attack_maps = {}, {}
for i,unit in ipairs(units) do
my_attack_maps[i] = battle_calcs.get_attack_map_unit(unit)
end
for i,e in ipairs(enemies) do
enemy_attack_maps[i] = battle_calcs.get_attack_map_unit(e)
end
-- Get the damage rating for each unit in 'units'. It is the maximum
-- defender_rating (roughly the damage that it can do in units of gold)
-- against any of the enemy units
local unit_ratings = {}
for i,unit in ipairs(units) do
local max_rating, best_enemy = - math.huge, {}
for _,enemy in ipairs(enemies) do
local rating, defender_rating, attacker_rating =
battle_calcs.attack_rating(unit, enemy, { unit.x, unit.y }, { enemy_leader_weight = 1 }, cache)
local eff_rating = defender_rating
if (eff_rating > max_rating) then
max_rating = eff_rating
best_enemy = enemy
end
end
unit_ratings[i] = { rating = max_rating, unit_id = unit.id, enemy_id = best_enemy.id }
end
-- Then we want the same thing for all of the enemy units (for the counter attack on enemy turn)
local enemy_ratings = {}
for i,enemy in ipairs(enemies) do
local max_rating, best_unit = - math.huge, {}
for _,unit in ipairs(units) do
local rating, defender_rating, attacker_rating =
battle_calcs.attack_rating(enemy, unit, { enemy.x, enemy.y }, { enemy_leader_weight = 1 }, cache)
local eff_rating = defender_rating
if (eff_rating > max_rating) then
max_rating = eff_rating
best_unit = unit
end
end
enemy_ratings[i] = { rating = max_rating, unit_id = best_unit.id, enemy_id = enemy.id }
end
-- The damage map is now the sum of these ratings for each unit that can attack a given hex,
-- counting own-unit ratings as positive, enemy ratings as negative
local damage_map, own_damage_map, enemy_damage_map = LS.create(), LS.create(), LS.create()
for i,_ in ipairs(units) do
my_attack_maps[i].units:iter(function(x, y, v)
own_damage_map:insert(x, y, (own_damage_map:get(x, y) or 0) + unit_ratings[i].rating)
damage_map:insert(x, y, (damage_map:get(x, y) or 0) + unit_ratings[i].rating)
end)
end
for i,_ in ipairs(enemies) do
enemy_attack_maps[i].units:iter(function(x, y, v)
enemy_damage_map:insert(x, y, (enemy_damage_map:get(x, y) or 0) + enemy_ratings[i].rating)
damage_map:insert(x, y, (damage_map:get(x, y) or 0) - enemy_ratings[i].rating)
end)
end
return damage_map, own_damage_map, enemy_damage_map
end
function battle_calcs.best_defense_map(units, cfg)
-- Get a defense rating map of all hexes all units in @units can reach
-- For each hex, the value is the maximum of any of the units that can reach that hex
-- @cfg: table with config parameters
-- max_moves: if set use max_moves for units (this setting is always used for units on other sides)
-- ignore_these_units: table of enemy units whose ZoC is to be ignored for route finding
cfg = cfg or {}
local defense_map = LS.create()
if cfg.ignore_these_units then
for _,unit in ipairs(cfg.ignore_these_units) do unit:extract() end
end
for _,unit in ipairs(units) do
-- Set max_moves according to the cfg value
local max_moves = cfg.max_moves
-- For unit on other than current side, only max_moves=true makes sense
if (unit.side ~= wesnoth.current.side) then max_moves = true end
local old_moves = unit.moves
if max_moves then unit.moves = unit.max_moves end
local reach = wesnoth.paths.find_reach(unit, cfg)
if max_moves then unit.moves = old_moves end
for _,loc in ipairs(reach) do
local defense = unit:defense_on(wesnoth.current.map[loc])
if (defense > (defense_map:get(loc[1], loc[2]) or - math.huge)) then
defense_map:insert(loc[1], loc[2], defense)
end
end
end
if cfg.ignore_these_units then
for _,unit in ipairs(cfg.ignore_these_units) do unit:to_map() end
end
return defense_map
end
function battle_calcs.get_attack_combos_subset(units, enemy, cfg)
-- Calculate combinations of attacks by @units on @enemy
-- This method does *not* produce all possible attack combinations, but is
-- meant to have a good chance to find either the best combination,
-- or something close to it, by only considering a subset of all possibilities.
-- It is also configurable to stop accumulating combinations when certain criteria are met.
--
-- The return value is an array of attack combinations, where each element is another
-- array of tables containing 'dst' and 'src' fields of the attacking units. It can be
-- specified whether the order of the attacks matters or not (see below).
--
-- Note: This function is optimized for speed, not elegance
--
-- Note 2: The structure of the returned table is different from the (current) return value
-- of ai_helper.get_attack_combos(), since the order of attacks never matters for the latter.
-- TODO: consider making the two consistent (not sure yet whether that is advantageous)
--
-- @cfg: Table of optional configuration parameters
-- - order_matters: if set, keep attack combos that use the same units on the same
-- hexes, but in different attack order (default: false)
-- - max_combos: stop adding attack combos if this number of combos has been reached
-- default: assemble all possible combinations
-- - max_time: stop adding attack combos if this much time (in seconds) has passed
-- default: assemble all possible combinations
-- note: this counts the time from the first call to add_attack(), not to
-- get_attack_combos_cfg(), so there's a bit of extra overhead in here.
-- This is done to prevent the return of no combos at all
-- Note 2: there is some overhead involved in reading the time from the system,
-- so don't use this unless it's needed
-- - skip_presort: by default, the units are presorted in order of the unit with
-- the highest rating first. This has the advantage of likely finding the best
-- (or at least close to the best) attack combination earlier, but it add overhead,
-- so it's actually a disadvantage for small numbers of combinations. skip_presort
-- specifies the number of units up to which the presorting is skipped. Default: 5
cfg = cfg or {}
cfg.order_matters = cfg.order_matters or false
cfg.max_combos = cfg.max_combos or math.huge
cfg.max_time = cfg.max_time or false
cfg.skip_presort = cfg.skip_presort or 5
----- begin add_attack() -----
-- Recursive local function adding another attack to the current combo
-- and adding the current combo to the overall attack_combos array
local function add_attack(attacks, reachable_hexes, n_reach, attack_combos, combos_str, current_combo, hexes_used, inner_cfg)
local time_up = false
if inner_cfg.max_time and (wesnoth.ms_since_init() / 1000. - inner_cfg.start_time >= inner_cfg.max_time) then
time_up = true
end
-- Go through all the units
for ind_att,attack in ipairs(attacks) do -- 'attack' is array of all attacks for the unit
-- Then go through the individual attacks of the unit ...
for _,att in ipairs(attack) do
-- But only if this hex is not used yet and
-- the cutoff criteria are not met
if (not hexes_used[att.dst]) and (not time_up) and (#attack_combos < inner_cfg.max_combos) then
-- Mark this hex as used by this unit
hexes_used[att.dst] = attack.src
-- Set up a string uniquely identifying the unit/attack hex pairs
-- for current_combo. This is used to exclude pairs that already
-- exist in a different order (if 'inner_cfg.order_matters' is not set)
-- For this, we also add the numerical value of the attack_hex to
-- the 'hexes_used' table (in addition to the line above)
local str = ''
if (not inner_cfg.order_matters) then
hexes_used[reachable_hexes[att.dst]] = attack.src
for ind_hex = 1,n_reach do
if hexes_used[ind_hex] then
str = str .. hexes_used[ind_hex] .. '-'
else
str = str .. '0-'
end
end
end
-- 'combos_str' contains all the strings of previous combos
-- (if 'inner_cfg.order_matters' is not set)
-- Only add this combo if it does not yet exist
if (not combos_str[str]) then
-- Add the string identifyer to the array
if (not inner_cfg.order_matters) then
combos_str[str] = true
end
-- Add the attack to 'current_combo'
table.insert(current_combo, { dst = att.dst, src = attack.src })
-- And *copy* the content of 'current_combo' into 'attack_combos'
local n_combos = #attack_combos + 1
attack_combos[n_combos] = {}
for ind_combo,combo in pairs(current_combo) do attack_combos[n_combos][ind_combo] = combo end
-- Finally, remove the current unit for 'attacks' for the call to the next recursion level
table.remove(attacks, ind_att)
add_attack(attacks, reachable_hexes, n_reach, attack_combos, combos_str, current_combo, hexes_used, cfg)
-- Reinsert the unit
table.insert(attacks, ind_att, attack)
-- And remove the last element (current attack) from 'current_combo'
table.remove(current_combo)
end
-- And mark the hex as usable again
if (not inner_cfg.order_matters) then
hexes_used[reachable_hexes[att.dst]] = nil
end
hexes_used[att.dst] = nil
-- *** Important ***: We *only* consider one attack hex per unit, the
-- first that is found in the array of attacks for the unit. As they
-- are sorted by terrain defense, we simply use the first in the table
-- the unit can reach that is not occupied
-- That's what the 'break' does here:
break
end
end
end
end
----- end add_attack() -----
-- For units on the current side, we need to make sure that
-- there isn't a unit of the same side in the way that cannot move any more
-- Set up an array of hexes blocked in such a way
-- For units on other sides we always assume that they can move away
local blocked_hexes = LS.create()
if units[1] and (units[1].side == wesnoth.current.side) then
for xa,ya in wesnoth.current.map:iter_adjacent(enemy) do
local unit_in_way = wesnoth.units.get(xa, ya)
if unit_in_way then
-- Units on the same side are blockers if they cannot move away
if (unit_in_way.side == wesnoth.current.side) then
local reach = wesnoth.paths.find_reach(unit_in_way)
if (#reach <= 1) then
blocked_hexes:insert(unit_in_way.x, unit_in_way.y)
end
else -- Units on other sides are always blockers
blocked_hexes:insert(unit_in_way.x, unit_in_way.y)
end
end
end
end
-- For sides other than the current, we always use max_moves,
-- for the current side we always use current moves
local old_moves = {}
for i,unit in ipairs(units) do
if (unit.side ~= wesnoth.current.side) then
old_moves[i] = unit.moves
unit.moves = unit.max_moves
end
end
-- Now set up an array containing the attack locations for each unit
local attacks = {}
-- We also need a numbered array of the possible attack hex coordinates
-- The order doesn't matter, as long as it is fixed
local reachable_hexes = {}
for i,unit in ipairs(units) do
local locs = {} -- attack locations for this unit
for xa,ya in wesnoth.current.map:iter_adjacent(enemy) do
local loc = {} -- attack location information for this unit for this hex
-- Make sure the hex is not occupied by unit that cannot move out of the way
if (not blocked_hexes:get(xa, ya) or ((xa == unit.x) and (ya == unit.y))) then
-- Check whether the unit can get to the hex
-- wesnoth.map.distance_between() is much faster than wesnoth.paths.find_path()
--> pre-filter using the former
local cost = M.distance_between(unit.x, unit.y, xa, ya)
-- If the distance is <= the unit's MP, then see if it can actually get there
-- This also means that only short paths have to be evaluated (in most situations)
if (cost <= unit.moves) then
local path -- since cost is already defined outside this block
path, cost = AH.find_path_with_shroud(unit, xa, ya)
end
-- If the unit can get to this hex
if (cost <= unit.moves) then
-- Store information about it in 'loc' and add this to 'locs'
-- Want coordinates (dst) and terrain defense (for sorting)
loc.dst = xa * 1000 + ya
loc.hit_prob = 100 - unit:defense_on(wesnoth.current.map[{xa, ya}])
table.insert(locs, loc)
-- Also mark this hex as usable
reachable_hexes[loc.dst] = true
end
end
end
-- Also add some top-level information for the unit
if locs[1] then
locs.src = unit.x * 1000 + unit.y -- The current position of the unit
locs.unit_i = i -- The position of the unit in the 'units' array
-- Now sort the possible attack locations for this unit by terrain defense
table.sort(locs, function(a, b) return a.hit_prob < b.hit_prob end)
-- Finally, add the attack locations for this unit to the 'attacks' array
table.insert(attacks, locs)
end
end
-- Reset moves for all units
for i,unit in ipairs(units) do
if (unit.side ~= wesnoth.current.side) then
unit.moves = old_moves[i]
end
end
-- If the number of units that can attack is greater than cfg.skip_presort:
-- We also sort the attackers by their attack rating on their favorite hex
-- The motivation is that by starting with the strongest unit, we'll find the
-- best attack combo earlier, and it is more likely to find the best (or at
-- least a good combo) even when not all attack combinations are collected.
if (#attacks > cfg.skip_presort) then
for _,attack in ipairs(attacks) do
local dst = attack[1].dst
local x, y = math.floor(dst / 1000), dst % 1000
attack.rating = battle_calcs.attack_rating(units[attack.unit_i], enemy, { x, y })
end
table.sort(attacks, function(a, b) return a.rating > b.rating end)
end
-- To simplify and speed things up in the following, the field values
-- 'reachable_hexes' table needs to be consecutive integers
-- We also want a variable containing the number of elements in the array
-- (#reachable_hexes doesn't work because they keys are location indices)
local n_reach = 0
for k,hex in pairs(reachable_hexes) do
n_reach = n_reach + 1
reachable_hexes[k] = n_reach
end
-- If cfg.max_time is set, record the start time
-- For convenience, we store this in cfg
if cfg.max_time then
cfg.start_time = wesnoth.ms_since_init() / 1000.
end
-- All this was just setting up the required information, now we call the
-- recursive function setting up the array of attackcombinations
local attack_combos = {} -- This will contain the return value
-- Temporary arrays (but need to be persistent across the recursion levels)
local combos_str, current_combo, hexes_used = {}, {}, {}
add_attack(attacks, reachable_hexes, n_reach, attack_combos, combos_str, current_combo, hexes_used, cfg)
cfg.start_time = nil
return attack_combos
end
return battle_calcs