wesnoth/data/ai/lua/ai_helper.lua

2547 lines
100 KiB
Lua

local LS = wesnoth.require "location_set"
local F = wesnoth.require "functional"
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.
-- Invisible units ('viewing_side' and 'ignore_visibility' parameters):
-- With their default settings, the ai_helper functions use the vision a player of
-- the respective side would see, that is, they assume no knowledge of invisible
-- units. This can be influenced with the 'viewing_side' and 'ignore_visibility' parameters,
-- which work in the same way as they do in wesnoth.paths.find_reach() and wesnoth.paths.find_path():
-- - If 'viewing_side' is set, vision for that side is used. It must be set to a valid side number.
-- - If 'ignore_visibility' is set to true, all units on the map are seen and shroud is ignored.
-- This overrides 'viewing_side'.
-- - If neither parameter is given and a function takes a parameter linked to a specific side,
-- such as a side number or a unit, as input, vision of that side is used.
-- - For some functions that take no other side-related input, 'viewing_side' is made a required parameter.
---@class ai_helper_visibility_opts
---@field viewing_side? integer If set, vision for that side is used. It must be set to a valid side number. Typically defaults to the side of a specified unit if not set.
---@field ignore_visibility? boolean If set to true, all units on the map are seen and shroud is ignored. This overrides 'viewing_side'.
-- Path finding:
-- All ai_helper functions disregard shroud for path finding (while still ignoring
-- hidden units correctly) as of Wesnoth 1.13.7. This is consistent with default
-- Wesnoth AI behavior and ensures that Lua AIs, including the Micro AIs, can be
-- used for AI sides with shroud=yes. It is accomplished by using
-- ai_helper.find_path_with_shroud() instead of wesnoth.paths.find_path().
---@class ai_helper_lib
local ai_helper = {}
----- Debugging helper functions ------
---Prints out a representation of an HP distribution table
---@param hp_distribution table<integer, number> The HP distribution table, for example from wesnoth.simulate_combat
---@param print? function Optional print function. Pass std_print if you prefer output to the console. Any other function taking a single argument will also work.
function ai_helper.print_hp_distribution(hp_distribution, print)
print = print or _G.print
-- hp_distribution is sort of an array, but unlike most Lua arrays it's 0-index
for i = 0, #hp_distribution - 1 do
if hp_distribution[i] > 0 then
print(('P(hp = $hp) = $prob%'):vformat{hp = i, prob = hp_distribution[i] * 100})
end
end
end
---Returns true or false (hard-coded). To be used to show messages if in debug mode.
---@return boolean
function ai_helper.show_messages()
-- Just edit the following line (easier than trying to set WML variable)
local show_messages_flag = false
if wesnoth.game_config.debug then return show_messages_flag end
return false
end
---Returns true or false (hard-coded). To be used to show which CA is being executed if in debug mode.
---@return boolean
function ai_helper.print_exec()
-- Just edit the following line (easier than trying to set WML variable)
local print_exec_flag = false
if wesnoth.game_config.debug then return print_exec_flag end
return false
end
---Returns true or false (hard-coded). To be used to show which CA is being evaluated if in debug mode.
---@return boolean
function ai_helper.print_eval()
-- Just edit the following line (easier than trying to set WML variable)
local print_eval_flag = false
if wesnoth.game_config.debug then return print_eval_flag end
return false
end
---@param start_time integer
---@param ca_name string
function ai_helper.done_eval_messages(start_time, ca_name)
ca_name = ca_name or 'unknown'
if ai_helper.print_eval() then
ai_helper.print_ts_delta(start_time, ' - Done evaluating ' .. ca_name .. ':')
end
end
---Clear all labels on a map
function ai_helper.clear_labels()
for x, y in wesnoth.current.map:iter(true) do
M.add_label { x = x, y = y, text = "" }
end
end
---@class ai_debug_label_opts
---@field show_coords? boolean Use hex coordinates as labels instead of value
---@field factor? number If value is a number, multiply by this factor
---@field keys? any[] If the value to be displayed is a subelement of the LS data, use these keys to access it. For example, if we want to display data[3], set keys = { 3 }; if we want data.arg[3], set keys = { 'arg', 3 }
---@field clear? boolean if set to 'false', do not clear existing labels
---@field color? color the color to be used for the output
---Take map (location set) and put labels containing 'value' onto the map
---Print 'nan' if element exists but is not a number.
---@param map location_set The labels to place on the map.
---@param cfg? ai_debug_label_opts table with optional configuration parameters:
function ai_helper.put_labels(map, cfg)
cfg = cfg or {}
local factor = cfg.factor or 1
local clear_labels = cfg.clear
if (clear_labels == nil) then clear_labels = true end
if clear_labels then
ai_helper.clear_labels()
end
map:iter(function(x, y, data)
local out
if cfg.show_coords then
out = x .. ',' .. y
else
if cfg.keys then
for _,key in ipairs(cfg.keys) do data = data[key] end
end
if (type(data) == 'string') then
out = data
else
out = tonumber(data) or 'nan'
end
end
if (type(out) == 'number') then out = out * factor end
M.add_label { x = x, y = y, text = out, color = cfg.color }
end)
end
---Print arguments preceded by a time stamp in seconds.
---Also return that time stamp
---@param ... unknown
---@return number
function ai_helper.print_ts(...)
local ts = wesnoth.ms_since_init() / 1000.
local arg = { ... }
arg[#arg+1] = string.format('[ t = %.3f ]', ts)
std_print(table.unpack(arg))
return ts
end
---Same as ai_helper.print_ts(), but also adds time elapsed since
---the time given in the first argument (in seconds)
---Returns time stamp as well as time elapsed
---@param start_time integer Time stamp in seconds as returned by wesnoth.ms_since_init / 1000.
---@param ... unknown
---@return number
---@return number
function ai_helper.print_ts_delta(start_time, ...)
local ts = wesnoth.ms_since_init() / 1000.
local delta = ts - start_time
local arg = { ... }
arg[#arg+1] = string.format('[ t = %.3f, dt = %.3f ]', ts, delta)
std_print(table.unpack(arg))
return ts, delta
end
----- AI execution helper functions ------
---Checks if the result of a move indicates that it was incomplete
---@param check ai_result
---@return integer|false
function ai_helper.is_incomplete_move(check)
if (not check.ok) then
-- Legitimately interrupted moves have the following error codes:
-- E_AMBUSHED = 2005
-- E_FAILED_TELEPORT = 2006,
-- E_NOT_REACHED_DESTINATION = 2007
if (check.status == 2005) or (check.status == 2006) or (check.status == 2007) then
return check.status
end
end
return false
end
---Check if the result of a move indicates that it was either incomplete or empty.
---An empty move means an attempt to move the hex the unit is already on.
---@param check ai_result
---@return integer|false
function ai_helper.is_incomplete_or_empty_move(check)
if (not check.ok) then
-- Empty moves have the following error code:
-- E_EMPTY_MOVE = 2001
if ai_helper.is_incomplete_move(check) or (check.status == 2001) then
return check.status
end
end
return false
end
---Construct a fake AI result table
---@param gamestate_changed? boolean
---@param ok? boolean
---@param result? string
---@param status? number
---@return ai_result
function ai_helper.dummy_check_action(gamestate_changed, ok, result, status)
return {
gamestate_changed = gamestate_changed or false,
ok = ok or false,
result = result or 'ai_helper::DUMMY_FAILED_ACTION',
status = status or 99999
}
end
---Print an error message about a failed action if debug more is enabled
---@param action string
---@param error_code string|number
function ai_helper.checked_action_error(action, error_code)
if wesnoth.game_config.debug then
error(action .. ' could not be executed. Error code: ' .. error_code, 3)
end
end
---Check if an attack is viable, and execute it if it is.
---If the attack is not viable, the unit's attacks left are cleared.
---@param ai ailib
---@param attacker unit
---@param defender unit
---@param weapon integer
---@return ai_result
function ai_helper.checked_attack(ai, attacker, defender, weapon)
local check = ai.check_attack(attacker, defender, weapon)
if (not check.ok) then
ai.stopunit_attacks(attacker)
ai_helper.checked_action_error('ai.attack from ' .. attacker.x .. ',' .. attacker.y .. ' to ' .. defender.x .. ',' .. defender.y, check.status .. ' (' .. check.result .. ')')
return check
end
return ai.attack(attacker, defender, weapon)
end
---@param ai ailib
---@param unit unit
---@param x integer
---@param y integer
---@param move_type string
---@return ai_result
local function checked_move_core(ai, unit, x, y, move_type)
local check = ai.check_move(unit, x, y)
if (not check.ok) then
if (not ai_helper.is_incomplete_or_empty_move(check)) then
ai.stopunit_moves(unit)
ai_helper.checked_action_error(move_type .. ' from ' .. unit.x .. ',' .. unit.y .. ' to ' .. x .. ',' .. y, check.status .. ' (' .. check.result .. ')')
return check
end
end
if (move_type == 'ai.move_full') then
return ai.move_full(unit, x, y)
else
return ai.move(unit, x, y)
end
end
---Check if a move is viable, and execute it if it is.
---Whether the move is viable or not, the unit's moves left are cleared.
---@param ai ailib
---@param unit unit
---@param x integer
---@param y integer
---@return ai_result
function ai_helper.checked_move_full(ai, unit, x, y)
return checked_move_core(ai, unit, x, y, 'ai.move_full')
end
---Check if a move is viable, and execute it if it is.
---If the move is not viable, the unit's moves left are cleared.
---@param ai ailib
---@param unit unit
---@param x integer
---@param y integer
---@return ai_result
function ai_helper.checked_move(ai, unit, x, y)
return checked_move_core(ai, unit, x, y, 'ai.move')
end
---Check if a recruit is viable, and execute it if it is.
---@param ai ailib
---@param unit_type string
---@param x integer
---@param y integer
---@return ai_result
function ai_helper.checked_recruit(ai, unit_type, x, y)
local check = ai.check_recruit(unit_type, x, y)
if (not check.ok) then
ai_helper.checked_action_error('ai.recruit of ' .. unit_type .. ' at ' .. x .. ',' .. y, check.status .. ' (' .. check.result .. ')')
return check
end
return ai.recruit(unit_type, x, y)
end
---Check if it's viable to clear the unit's moves and attacks, and do so if it is.
---@param ai ailib
---@param unit unit
---@return ai_result
function ai_helper.checked_stopunit_all(ai, unit)
local check = ai.check_stopunit(unit)
if (not check.ok) then
ai_helper.checked_action_error('ai.stopunit_all of ' .. unit.x .. ',' .. unit.y, check.status .. ' (' .. check.result .. ')')
return check
end
return ai.stopunit_all(unit)
end
---Check if it's viable to clear the unit's attacks, and do so if it is.
---@param ai ailib
---@param unit unit
---@return ai_result
function ai_helper.checked_stopunit_attacks(ai, unit)
local check = ai.check_stopunit(unit)
if (not check.ok) then
ai_helper.checked_action_error('ai.stopunit_attacks of ' .. unit.x .. ',' .. unit.y, check.status .. ' (' .. check.result .. ')')
return check
end
return ai.stopunit_attacks(unit)
end
---Check if it's viable to clear the unit's moves, and do so if it is.
---@param ai ailib
---@param unit unit
---@return ai_result
function ai_helper.checked_stopunit_moves(ai, unit)
local check = ai.check_stopunit(unit)
if (not check.ok) then
ai_helper.checked_action_error('ai.stopunit_moves of ' .. unit.x .. ',' .. unit.y, check.status .. ' (' .. check.result .. ')')
return check
end
return ai.stopunit_moves(unit)
end
---@class robust_move_opts : move_out_of_way_opts
---@field partial_move boolean By default, this function performs full moves. If this parameter is true, a partial move is done instead.
---@field weapon integer The number (starting at 1) of the attack weapon to be used. If omitted, the best weapon is automatically selected.
---Perform a move and/or attack with an AI unit in a way that is robust against
---unexpected outcomes such as being ambushed or changes caused by WML events.
---As much as possible, this function also tries to ensure that the gamestate
---is changed in case an action turns out to be impossible due to such an
---unexpected outcome.
---@param ai ailib The ai module
---@param src location current coordinates of the AI unit to be used
---@param dst location coordinates to which the unit should move. This does not have to be different from src. In fact, the unit does not even need to have moves left, as long as an attack is specified in the latter case. If another AI unit is at dst, it is moved out of the way.
---@param target_loc? location Coordinates of the enemy unit to be attacked. If not given, no attack is attempted.
---@param cfg? robust_move_opts Additional configuration options
---@return table
function ai_helper.robust_move_and_attack(ai, src, dst, target_loc, cfg)
-- Notes:
-- - If an avoid_map is given in the options table, it is assumed that dst has been
-- checked to lie outside the area to avoid.
-- - src, dst and target_loc can be any table (including proxy units) that contains
-- the coordinates of the respective locations using either indices .x/.y or [1]/[2].
-- If both are given, .x/.y takes precedence over [1]/[2].
-- - This function only safeguards AI moves against outcomes that the AI cannot know
-- about, such as hidden units and WML events. It is assumed that the potential
-- move was tested for general feasibility (units are on AI side and have moves
-- left, terrain is passable, etc.) beforehand. If that is not done, it might
-- lead to very undesirable behavior, incl. the CA being blacklisted or even the
-- entire AI turn being ended.
local src_x, src_y = src.x or src[1], src.y or src[2] -- this works with units or locations
local dst_x, dst_y = dst.x or dst[1], dst.y or dst[2]
local unit = wesnoth.units.get(src_x, src_y)
if (not unit) then
return ai_helper.dummy_check_action(false, false, 'robust_move_and_attack::NO_UNIT')
end
-- Getting target at beginning also, in case events mess up things along the way
local target, target_x, target_y
if target_loc then
target_x, target_y = target_loc.x or target_loc[1], target_loc.y or target_loc[2]
target = wesnoth.units.get(target_x, target_y)
if (not target) then
return ai_helper.dummy_check_action(false, false, 'robust_move_and_attack::NO_TARGET')
end
end
local gamestate_changed = false
local move_result = ai_helper.dummy_check_action(false, false, 'robust_move_and_attack::NO_ACTION')
if (unit.moves > 0) then
if (src_x == dst_x) and (src_y == dst_y) then
move_result = ai.stopunit_moves(unit)
-- The only possible failure modes are non-recoverable (such as E_NOT_OWN_UNIT)
if (not move_result.ok) then return move_result end
if (not unit) or (not unit.valid) then
return ai_helper.dummy_check_action(true, false, 'robust_move_and_attack::UNIT_DISAPPEARED')
end
gamestate_changed = true
else
local unit_old_moves = unit.moves
local unit_in_way = wesnoth.units.get(dst_x, dst_y)
if unit_in_way and (unit_in_way.side == wesnoth.current.side) and (unit_in_way.moves > 0) then
local uiw_old_moves = unit_in_way.moves
ai_helper.move_unit_out_of_way(ai, unit_in_way, cfg)
if (not unit_in_way) or (not unit_in_way.valid) then
return ai_helper.dummy_check_action(true, false, 'robust_move_and_attack::UNIT_IN_WAY_DISAPPEARED')
end
-- Failed move out of way: abandon remaining actions
if (unit_in_way.x == dst_x) and (unit_in_way.y == dst_y) then
if (unit_in_way.moves == uiw_old_moves) then
-- Forcing a gamestate change, if necessary
ai.stopunit_moves(unit_in_way)
end
return ai_helper.dummy_check_action(true, false, 'robust_move_and_attack::UNIT_IN_WAY_EMPTY_MOVE')
end
-- Check whether dst hex is free now (an event could have done something funny)
local unit_in_way_temp = wesnoth.units.get(dst_x, dst_y)
if unit_in_way_temp then
return ai_helper.dummy_check_action(true, false, 'robust_move_and_attack::ANOTHER_UNIT_IN_WAY')
end
gamestate_changed = true
end
if (not unit) or (not unit.valid) or (unit.x ~= src_x) or (unit.y ~= src_y) then
return ai_helper.dummy_check_action(true, false, 'robust_move_and_attack::UNIT_DISAPPEARED')
end
local check_result = ai.check_move(unit, dst_x, dst_y)
if (not check_result.ok) then
if (not ai_helper.is_incomplete_or_empty_move(check_result)) then
if (not gamestate_changed) then
ai.stopunit_moves(unit)
end
return check_result
end
end
if cfg and cfg.partial_move then
move_result = ai.move(unit, dst_x, dst_y)
else
move_result = ai.move_full(unit, dst_x, dst_y)
end
if (not move_result.ok) then return move_result end
if (not unit) or (not unit.valid) then
return ai_helper.dummy_check_action(true, false, 'robust_move_and_attack::UNIT_DISAPPEARED')
end
-- Failed move: abandon rest of actions
if (unit.x == src_x) and (unit.y == src_y) then
if (not gamestate_changed) and (unit.moves == unit_old_moves) then
-- Forcing a gamestate change, if necessary
ai.stopunit_moves(unit)
end
return ai_helper.dummy_check_action(true, false, 'robust_move_and_attack::UNPLANNED_EMPTY_MOVE')
end
gamestate_changed = true
end
end
-- Tests after the move, before continuing to attack, to ensure WML events
-- did not do something funny
if (not unit) or (not unit.valid) then
return ai_helper.dummy_check_action(true, false, 'robust_move_and_attack::UNIT_DISAPPEARED')
end
if (unit.x ~= dst_x) or (unit.y ~= dst_y) then
return ai_helper.dummy_check_action(true, false, 'robust_move_and_attack::UNIT_NOT_AT_DESTINATION')
end
-- In case all went well and there's no attack to be done
if (not target_x) then return move_result end
if (not target) or (not target.valid) then
return ai_helper.dummy_check_action(true, false, 'robust_move_and_attack::TARGET_DISAPPEARED')
end
if (target.x ~= target_x) or (target.y ~= target_y) then
return ai_helper.dummy_check_action(true, false, 'robust_move_and_attack::TARGET_MOVED')
end
local weapon = cfg and cfg.weapon
local old_attacks_left = unit.attacks_left
local check_result = ai.check_attack(unit, target, weapon)
if (not check_result.ok) then
if (not gamestate_changed) then
ai.stopunit_all(unit)
end
return check_result
end
move_result = ai.attack(unit, target, weapon)
-- This should not happen, given that we just checked, but just in case
if (not move_result.ok) then return move_result end
if (not unit) or (not unit.valid) then
return ai_helper.dummy_check_action(true, false, 'robust_move_and_attack::UNIT_DISAPPEARED')
end
if (unit.attacks_left == old_attacks_left) and (not gamestate_changed) then
ai.stopunit_all(unit)
return ai_helper.dummy_check_action(true, false, 'robust_move_and_attack::NO_ATTACK')
end
return move_result
end
----- General functionality and maths helper functions ------
---Make a copy of a table (rather than just another pointer to the same table)
---Note: This is only a shallow copy. Any nested references to tables, including WML tags,
---will still remain as a reference to the original table.
---@param t table
---@return table
function ai_helper.table_copy(t)
local copy = {}
for k,v in pairs(t) do copy[k] = v end
return copy
end
---Merge two arrays without overwriting @a1 or @a2 -> create a new table
---This only works with arrays, not general tables
---@param a1 table
---@param a2 table
---@return table
function ai_helper.array_merge(a1, a2)
local merger = {}
for _,a in pairs(a1) do table.insert(merger, a) end
for _,a in pairs(a2) do table.insert(merger, a) end
return merger
end
---Convert input to a string in a format corresponding to the type of input
---The string is all put into one line
---@param input any
---@return string
function ai_helper.serialize(input)
local str = ''
if (type(input) == "number") or (type(input) == "boolean") then
str = tostring(input)
elseif type(input) == "string" then
str = string.format("%q", input)
elseif type(input) == "table" then
str = str .. "{ "
for k,v in pairs(input) do
str = str .. "[" .. ai_helper.serialize(k) .. "] = "
str = str .. ai_helper.serialize(v)
str = str .. ", "
end
str = str .. "}"
else
error("cannot serialize a " .. type(input), 2)
end
return str
end
---Split string str into a table using the delimiter sep (default: ',')
---@param str string
---@param sep string
---@return table
function ai_helper.split(str, sep)
sep = sep or ","
local fields = {}
local pattern = string.format("([^%s]+)", sep)
local _ = string.gsub(str, pattern, function(c) fields[#fields+1] = c end)
return fields
end
ai_helper.split = wesnoth.deprecate_api('ai_helper.split', 'stringx.split', 3, '1.20', ai_helper.split)
--------- Location set related helper functions ----------
---Get the x,y coordinates for the index of a location set
---For some reason, there doesn't seem to be a LS function for this
---@param index integer
---@return integer
---@return integer
function ai_helper.get_LS_xy(index)
local tmp_set = LS.of_raw{[index] = true}
local xy = tmp_set:to_pairs()[1]
return xy.x, xy.y
end
--------- Location, position or hex related helper functions ----------
---Converts coordinates from hex geometry to cartesian coordinates,
---meaning that y coordinates are offset by 0.5 every other hex
---Example: (1,1) stays (1,1) and (3,1) remains (3,1), but (2,1) -> (2,1.5) etc.
---@param x integer
---@param y integer
---@return number
---@return number
function ai_helper.cartesian_coords(x, y)
return x, y + ((x + 1) % 2) / 2.
end
---Returns the angle of the direction from @from_hex to @to_hex
---Angle is in radians and goes from -pi to pi. 0 is toward east.
---Input hex tables can be of form { x, y } or { x = x, y = y }, which
---means that it is also possible to pass a unit table
---@param from_hex location
---@param to_hex location
---@return number
function ai_helper.get_angle(from_hex, to_hex)
local x1, y1 = from_hex.x or from_hex[1], from_hex.y or from_hex[2]
local x2, y2 = to_hex.x or to_hex[1], to_hex.y or to_hex[2]
local _, y1cart = ai_helper.cartesian_coords(x1, y1)
local _, y2cart = ai_helper.cartesian_coords(x2, y2)
return math.atan(y2cart - y1cart, x2 - x1)
end
---Returns an integer index for the direction from from_hex to to_hex
---with the full circle divided into n slices
---1 is always to the east, with indices increasing clockwise
---Input hex tables can be of form { x, y } or { x = x, y = y }, which
---means that it is also possible to pass a unit table
---@param from_hex location
---@param to_hex location
---@param n integer
---@param center_on_east? boolean By default, the eastern direction is the northern border of the first slice. If this parameter is set, east will instead be the center direction of the first slice
---@return integer
function ai_helper.get_direction_index(from_hex, to_hex, n, center_on_east)
local d_east = 0
if center_on_east then d_east = 0.5 end
local angle = ai_helper.get_angle(from_hex, to_hex)
local index = math.floor((angle / math.pi * n/2 + d_east) % n ) + 1
return index
end
---@param from_hex location
---@param to_hex location
---@return string
function ai_helper.get_cardinal_directions(from_hex, to_hex)
local dirs = { "E", "S", "W", "N" }
return dirs[ai_helper.get_direction_index(from_hex, to_hex, 4, true)]
end
---@param from_hex location
---@param to_hex location
---@return string
function ai_helper.get_intercardinal_directions(from_hex, to_hex)
local dirs = { "E", "SE", "S", "SW", "W", "NW", "N", "NE" }
return dirs[ai_helper.get_direction_index(from_hex, to_hex, 8, true)]
end
---@param from_hex location
---@param to_hex location
---@return string
function ai_helper.get_hex_facing(from_hex, to_hex)
local dirs = { "se", "s", "sw", "nw", "n", "ne" }
return dirs[ai_helper.get_direction_index(from_hex, to_hex, 6)]
end
---Find the hex that is opposite of hex with respect to center_hex
---Returns nil if hex and centre_hex are not adjacent.
---@param hex location
---@param center_hex location
---@return location?
function ai_helper.find_opposite_hex_adjacent(hex, center_hex)
-- If the two input hexes are not adjacent, return nil
if (M.distance_between(hex, center_hex) ~= 1) then return nil end
-- Finding the opposite x position is easy
local opp_x = center_hex.x + (center_hex.x - hex.x)
-- y is slightly more tricky, because of the hexagonal shape, but there's a trick
-- that saves us from having to build in a lot of if statements
-- Among the adjacent hexes, it is the one with the correct x, and y _different_ from hex[2]
for xa,ya in wesnoth.current.map:iter_adjacent(center_hex) do
if (xa == opp_x) and (ya ~= hex.y) then
return wesnoth.named_tuple({ xa, ya }, { 'x', 'y' })
end
end
return nil
end
---Find the hex that is opposite of @hex with respect to @center_hex
---Using "square coordinate" method by JaMiT
---Note: this also works for non-adjacent hexes, but might return hexes that are not on the map!
---@param hex location
---@param center_hex location
---@return location
function ai_helper.find_opposite_hex(hex, center_hex)
-- Finding the opposite x position is easy
local opp_x = center_hex.x + (center_hex.x - hex.x)
-- Going to "square geometry" for y coordinate
local y_sq = hex.y * 2 - (hex.x % 2)
local yc_sq = center_hex.y * 2 - (center_hex.x % 2)
-- Now the same equation as for x can be used for y
local opp_y = yc_sq + (yc_sq - y_sq)
opp_y = math.floor((opp_y + 1) / 2)
return wesnoth.named_tuple({opp_x, opp_y}, {'x', 'y'})
end
---Returns true if hex1 and hex2 are opposite from each other with respect to @center_hex
---@param hex1 location
---@param hex2 location
---@param center_hex location
---@return boolean
function ai_helper.is_opposite_adjacent(hex1, hex2, center_hex)
local opp_hex = ai_helper.find_opposite_hex_adjacent(hex1, center_hex)
if opp_hex and (opp_hex.x == hex2.x) and (opp_hex.y == hex2.y) then return true end
return false
end
---Get coordinates for either a named location or from x/y coordinates specified
---in @cfg. The location can be provided:
--- - as name: cfg[param_core .. '_loc'] (string)
--- - or as coordinates: cfg[param_core .. '_x'] and cfg[param_core .. '_y'] (integers)
---This is the syntax used by many Micro AIs.
---Exception to this variable name syntax: if param_core = '', then the location
---variables are 'location_id', 'x' and 'y'
---
---An error is raised if the named location does not exist, or if
---the coordinates are not on the map. In addition, if required_for
---is provided, an error is also raised if neither a named location
---nor both coordinates are provided. If required_for is not passed and neither
---input exists, nil is returned. Omitting required_for does not suppress
---the other two error conditions.
---@param param_core string The base name of the key to look up in the config.
---@param cfg WMLTable The AI configuration to find the location in.
---@param required_for? string A string to be included in the error message in the event of failure.
---@return location?
function ai_helper.get_named_loc_xy(param_core, cfg, required_for)
local param_loc = 'location_id'
if (param_core ~= '') then param_loc = param_core .. '_loc' end
local loc_id = cfg[param_loc]
if loc_id then
local loc = wesnoth.current.map.special_locations[loc_id]
if loc then
return loc
else
wml.error("Named location does not exist: " .. loc_id .. " " .. (required_for or ''))
end
end
local param_x, param_y = 'x', 'y'
if (param_core ~= '') then param_x, param_y = param_core .. '_x', param_core .. '_y' end
local x, y = cfg[param_x], cfg[param_y]
if x and y then
if not wesnoth.current.map:on_board(x, y) then
wml.error("Location is not on map: " .. param_x .. ',' .. param_y .. ' = ' .. x .. ',' .. y .. " " .. (required_for or ''))
end
return wesnoth.named_tuple({ x, y }, { 'x', 'y' })
end
if required_for then
wml.error(required_for .. " requires either " .. param_loc .. "= or " .. param_x .. "/" .. param_y .. "= keys")
end
return nil
end
---Same as ai_helper.get_named_loc_xy, except that it takes comma separated
---lists of locations.
---The result is returned as an array of locations.
---Empty table is returned if no locations are found.
---An error is raised if x and y are provided but the sizes don't match.
---The error conditions in get_named_loc_xy can also be raised.
---@param param_core string The base name of the key to look up in the config.
---@param cfg WMLTable The AI configuration to find the location in.
---@param required_for? string A string to be included in the error message in the event of failure.
---@return location[]
function ai_helper.get_multi_named_locs_xy(param_core, cfg, required_for)
local locs = {}
local param_loc = 'location_id'
if (param_core ~= '') then param_loc = param_core .. '_loc' end
local cfg_loc = cfg[param_loc]
if cfg_loc then
local loc_ids = stringx.split(cfg_loc, ",")
for _,loc_id in ipairs(loc_ids) do
local tmp_cfg = {}
tmp_cfg[param_loc] = loc_id
local loc = ai_helper.get_named_loc_xy(param_core, tmp_cfg, required_for)
table.insert(locs, loc)
end
return locs
end
local param_x, param_y = 'x', 'y'
if (param_core ~= '') then param_x, param_y = param_core .. '_x', param_core .. '_y' end
local cfg_x, cfg_y = cfg[param_x], cfg[param_y]
if cfg_x and cfg_y then
local xs = stringx.split(cfg_x, ",")
local ys = stringx.split(cfg_y, ",")
if (#xs ~= #ys) then
wml.error("Coordinate lists need to have same number of elements: " .. param_x .. ' and ' .. param_y)
end
for i,x in ipairs(xs) do
local tmp_cfg = {}
tmp_cfg[param_x] = tonumber(x)
tmp_cfg[param_y] = tonumber(ys[i])
local loc = ai_helper.get_named_loc_xy(param_core, tmp_cfg, required_for)
table.insert(locs, loc)
end
return locs
end
if required_for then
wml.error(required_for .. " requires either " .. param_loc .. "= or " .. param_x .. "/" .. param_y .. "= keys")
end
return locs
end
---Returns the same locations array as wesnoth.map.find(location_filter),
---but excluding hexes on the map border.
---
---Note that this might not work if location_filter is a vconfig object.
---@param location_filter WMLTable
---@return terrain_hex[]
function ai_helper.get_locations_no_borders(location_filter)
local old_include_borders = location_filter.include_borders
location_filter.include_borders = false
local locs = wesnoth.map.find(location_filter)
location_filter.include_borders = old_include_borders
return locs
end
---Get the location closest to hex (in format { x, y })
---that matches location_filter (in WML table format)
---Returns nil if no terrain matching the filter was found
---@param hex location Anchor location
---@param location_filter WMLTable Filter to match
---@param unit? unit Can be passed as an optional third parameter, in which case the terrain needs to be passable for that unit.
---@param avoid_map? location_set If given, the hexes in avoid_map are excluded
---@return terrain_hex?
function ai_helper.get_closest_location(hex, location_filter, unit, avoid_map)
-- Find the maximum distance from 'hex' that's possible on the map
local max_distance = 0
local map = wesnoth.current.map
local to_top_left = M.distance_between(hex, 0, 0)
if (to_top_left > max_distance) then max_distance = to_top_left end
local to_top_right = M.distance_between(hex, map.width-1, 0)
if (to_top_right > max_distance) then max_distance = to_top_right end
local to_bottom_left = M.distance_between(hex, 0, map.height-1)
if (to_bottom_left > max_distance) then max_distance = to_bottom_left end
local to_bottom_right = M.distance_between(hex, map.width-1, map.height-1)
if (to_bottom_right > max_distance) then max_distance = to_bottom_right end
-- If the hex is supposed to be passable for a unit, it cannot be on the map border
local include_borders
if unit then include_borders = 'no' end
local radius = 0
while (radius <= max_distance) do
local loc_filter = {}
if (radius == 0) then
loc_filter = {
wml.tag["and"] { x = hex.x, y = hex.y, include_borders = include_borders, radius = radius },
wml.tag["and"] ( location_filter )
}
else
loc_filter = {
wml.tag["and"] { x = hex.x, y = hex.y, include_borders = include_borders, radius = radius },
wml.tag["not"] { x = hex.x, y = hex.y, radius = radius - 1 },
wml.tag["and"] ( location_filter )
}
end
local locs = wesnoth.map.find(loc_filter)
if avoid_map then
for i = #locs,1,-1 do
if avoid_map:get(locs[i]) then
table.remove(locs, i)
end
end
end
if unit then
for _,loc in ipairs(locs) do
local movecost = unit:movement_on(wesnoth.current.map[loc])
if (movecost <= unit.max_moves) then return loc end
end
else
if locs[1] then return locs[1] end
end
radius = radius + 1
end
return nil
end
---Finds all locations matching location_filter that are passable for
---the unit. This also excludes hexes on the map border.
---@param location_filter WMLTable Filter
---@param unit unit Unit to check passability; if omitted, all hexes matching the filter, but excluding border hexes are returned
---@return terrain_hex[]
function ai_helper.get_passable_locations(location_filter, unit)
-- All hexes that are not on the map border
local all_locs = ai_helper.get_locations_no_borders(location_filter)
-- If a unit is provided, exclude terrain that's impassable for the unit
if unit then
local locs = {}
for _,loc in ipairs(all_locs) do
local movecost = unit:movement_on(wesnoth.current.map[loc])
if (movecost <= unit.max_moves) then table.insert(locs, loc) end
end
return locs
end
return all_locs
end
---Finds all locations matching @location_filter that provide healing, excluding border hexes.
---@param location_filter WMLTable
---@return terrain_hex[]
function ai_helper.get_healing_locations(location_filter)
local all_locs = ai_helper.get_locations_no_borders(location_filter)
local locs = {}
for _,loc in ipairs(all_locs) do
if wesnoth.terrain_types[wesnoth.current.map[loc]].healing > 0 then
table.insert(locs, loc)
end
end
return locs
end
---Get the distance map DM for specified units (as a location set)
---DM = sum ( distance_from_unit )
---This is done for all elements of map (a location set), or for the entire map if map is not given
---@param units unit[]
---@param map? location_set
---@return location_set
function ai_helper.distance_map(units, map)
local DM = LS.create()
if map then
map:iter(function(x, y, data)
local dist = 0
for _,unit in ipairs(units) do
dist = dist + M.distance_between(unit, x, y)
end
DM:insert(x, y, dist)
end)
else
for x, y in wesnoth.current.map:iter() do
local dist = 0
for _,unit in ipairs(units) do
dist = dist + M.distance_between(unit, x, y)
end
DM:insert(x, y, dist)
end
end
return DM
end
---Get the inverse distance map IDM for specified units (as a location set)
---IDM = sum ( 1 / (distance_from_unit+1) )
---This is done for all elements of map (a location set), or for the entire map if map is not given
---@param units unit[]
---@param map? location_set
---@return location_set
function ai_helper.inverse_distance_map(units, map)
local IDM = LS.create()
if map then
map:iter(function(x, y, data)
local dist = 0
for _,unit in ipairs(units) do
dist = dist + 1. / (M.distance_between(unit, x, y) + 1)
end
IDM:insert(x, y, dist)
end)
else
for x, y in wesnoth.current.map:iter() do
local dist = 0
for _,unit in ipairs(units) do
dist = dist + 1. / (M.distance_between(unit, x, y) + 1)
end
IDM:insert(x, y, dist)
end
end
return IDM
end
---Determines distance of (x1,y1) from (x2,y2) even if
---x2 and y2 are not necessarily both given (or not numbers)
---@param x1 integer
---@param y1 integer
---@param x2 integer?
---@param y2 integer?
---@return integer
function ai_helper.generalized_distance(x1, y1, x2, y2)
-- Return 0 if neither is given
if (not x2) and (not y2) then return 0 end
-- If only one of the parameters is set
if (not x2) then return math.abs(y1 - y2) end
if (not y2) then return math.abs(x1 - x2) end
-- Otherwise, return standard distance
return M.distance_between(x1, y1, x2, y2)
end
---Convert a list of locations as returned by wesnoth.map.find into a pair of strings
---suitable for passing in as x,y coordinate lists to wesnoth.map.find.
---Could alternatively convert to a WML table and use the find_in argument, but this is simpler.
---@param list location[]
---@return string #All the X coordinates as a comma-separated list
---@return string #All the Y coordinates as a comma-separated list
function ai_helper.split_location_list_to_strings(list)
local locsx, locsy = {}, {}
for i,loc in ipairs(list) do
locsx[i] = loc.x
locsy[i] = loc.y
end
return table.concat(locsx, ","), table.concat(locsy, ",")
end
---Returns a location set of hexes to be avoided by the AI. Information about
---these hexes can be provided in different ways:
---1. If avoid_tag is passed, we always use that. An example of this is when a
--- Micro AI configuration contains an [avoid] tag
---2. If use_ai_aspect (boolean) is set, we use the avoid aspect of the default AI.
---3. default_avoid_tag is used when avoid_tag is not passed and either
--- use_ai_aspect == false or the default AI aspect is not set.
---@param ai ailib
---@param avoid_tag WMLTable A location filter loaded from the MicroAI configuration
---@param use_ai_aspect boolean Whether to check the default AI avoid aspect
---@param default_avoid_tag WMLTable A hard-coded location filter to use if the aspect is not set
---@return location_set
function ai_helper.get_avoid_map(ai, avoid_tag, use_ai_aspect, default_avoid_tag)
if avoid_tag then
return LS.of_pairs(wesnoth.map.find(avoid_tag))
end
if use_ai_aspect then
-- We need to be careful here as ai.aspects.avoid is an empty table both
-- when the aspect is not set and when no hexes match the [avoid] tag.
-- If @default_avoid_tag is not set, we can simply return the content of
-- the aspect, it does not matter why it is an empty array (if it is).
-- However, if @default_avoid_tag is set, we need to check whether the
-- [avoid] tag is set for the default AI or not.
if (not default_avoid_tag) then
return LS.of_pairs(ai.aspects.avoid)
else
local ai_tag = wml.get_child(wesnoth.sides[wesnoth.current.side].__cfg, 'ai')
for aspect in wml.child_range(ai_tag, 'aspect') do
if (aspect.id == 'avoid') then
local facet = wml.get_child(aspect, 'facet')
if facet or aspect.name ~= "composite_aspect" then
-- If there's a [facet] child, it's set as a composite aspect,
-- with at least one facet.
-- But it might not be a composite aspect; it could be
-- a Lua aspect or a standard aspect.
return LS.of_pairs(ai.aspects.avoid)
end
end
end
end
end
-- If we got here, that means neither @avoid_tag nor the default AI [avoid] aspect were used
if default_avoid_tag then
return LS.of_pairs(wesnoth.map.find(default_avoid_tag))
else
return LS.create()
end
end
--------- Unit related helper functions ----------
---Check that viewing_side is valid and set to an existing side
---@param viewing_side? integer
---@param function_str? string
function ai_helper.check_viewing_side(viewing_side, function_str)
if (not viewing_side) then
error('ai_helper: missing required parameter viewing_side', 2)
end
if (type(viewing_side) ~= 'number') or (not wesnoth.sides[viewing_side]) then
error('ai_helper: parameter viewing_side must be a valid side number', 2)
end
end
---Check if the specified leader is set to be passive.
---@param aspect_value boolean|string[] The value of ai.aspects.passive_leader
---@param id string The leader's ID
---@return boolean
function ai_helper.is_passive_leader(aspect_value, id)
if (type(aspect_value) == 'boolean') then return aspect_value end
for _,aspect_id in ipairs(aspect_value) do
if (aspect_id == id) then
return true
end
end
return false
end
---Find all living units on the map matching the specified filter.
---Living units is equivalent to non-petrified units.
---@param filter WMLTable
---@return unit[]
function ai_helper.get_live_units(filter)
-- Note: the order of the filters and the [and] tags are important for speed reasons
return wesnoth.units.find_on_map { wml.tag["not"] { status = "petrified" }, wml.tag["and"] ( filter ) }
end
---Find units who can move that match the specified filter.
---@param filter WMLTable
---@param exclude_guardians? boolean Whether to consider guardian units as able to move.
---@return unit[]
function ai_helper.get_units_with_moves(filter, exclude_guardians)
-- Optional input: @exclude_guardians: set to 'true' to exclude units with ai_special=guardian
-- Note: the order of the filters and the [and] tags are important for speed reasons
local exclude_status = 'petrified'
if exclude_guardians then
exclude_status = exclude_status .. ',guardian'
end
return wesnoth.units.find_on_map {
-- TODO: Should this also check for a case where the unit can't move because of terrain?
-- That is, all adjacent terrains have a movement cost higher than the unit's moves left.
wml.tag["and"] { formula = "moves > 0" },
wml.tag["not"] { status = exclude_status },
wml.tag["and"] ( filter )
}
end
---Find units who can attack that match the specified filte.
---@param filter WMLTable
---@return unit[]
function ai_helper.get_units_with_attacks(filter)
-- Note: the order of the filters and the [and] tags are important for speed reasons
return wesnoth.units.find_on_map {
wml.tag["and"] { formula = "attacks_left > 0 and size(attacks) > 0" },
wml.tag["not"] { status = "petrified" },
wml.tag["and"] ( filter )
}
end
---Get units that are visible to the specified side
---@param viewing_side integer Must be set to a valid side number. If visibility is to be ignored, use wesnoth.units.find_on_map() instead.
---@param filter? WMLTable Standard unit filter WML table for the units; defaults to all units.
---@return table
function ai_helper.get_visible_units(viewing_side, filter)
ai_helper.check_viewing_side(viewing_side)
local filter_plus_vision = {}
if filter then filter_plus_vision = ai_helper.table_copy(filter) end
table.insert(filter_plus_vision, wml.tag.filter_vision { side = viewing_side, visible = 'yes' })
local units = {}
local all_units = wesnoth.units.find_on_map{}
for _,unit in ipairs(all_units) do
if unit:matches(filter_plus_vision) then
table.insert(units, unit)
end
end
return units
end
---Check whether a unit exists and is visible to the specified side
---@param viewing_side integer Must be set to a valid side number
---@param unit unit
---@return boolean
function ai_helper.is_visible_unit(viewing_side, unit)
ai_helper.check_viewing_side(viewing_side)
if (not unit) then return false end
if unit:matches({ wml.tag.filter_vision { side = viewing_side, visible = 'no' } }) then
return false
end
return true
end
---@class get_enemies_opts : ai_helper_visibility_opts
---@field avoid_map location_set If given, an enemy is included only if it does not have at least one adjacent hex outside of avoid_map
---Attackable enemies are defined as being being
--- - enemies of the specified side,
--- - not petrified
--- - and visible to the side as defined in cfg.viewing_side and cfg.ignore_visibility.
--- - have at least one adjacent hex that is not inside an area to avoid
---For speed reasons, this is done separately, rather than calling ai_helper.get_visible_units().
---@param filter? WMLTable Standard unit filter WML table for the enemies
---@param side? integer Side number, if side other than current side is to be considered
---@param cfg? get_enemies_opts
---@return table
function ai_helper.get_attackable_enemies(filter, side, cfg)
side = side or wesnoth.current.side
local viewing_side = cfg and cfg.viewing_side or side
ai_helper.check_viewing_side(viewing_side)
local ignore_visibility = cfg and cfg.ignore_visibility
---@type WMLTable
local filter_plus_vision = {}
if filter then filter_plus_vision = ai_helper.table_copy(filter) end
if (not ignore_visibility) then
filter_plus_vision = {
wml.tag["and"] ( filter_plus_vision ),
wml.tag.filter_vision { side = viewing_side, visible = 'yes' }
}
end
local enemies = {}
local all_units = wesnoth.units.find_on_map{}
for _,unit in ipairs(all_units) do
if wesnoth.sides.is_enemy(side, unit.side)
and (not unit.status.petrified)
and unit:matches(filter_plus_vision)
then
local is_avoided = false
if cfg and cfg.avoid_map then
is_avoided = true
for xa,ya in wesnoth.current.map:iter_adjacent(unit) do
if (not cfg.avoid_map:get(xa, ya)) then
is_avoided = false
break
end
end
end
if (not is_avoided) then
table.insert(enemies, unit)
end
end
end
return enemies
end
---Check if a unit exists, is an enemy of the specifed side, is visible to the side as defined
---by cfg.viewing_side and cfg.ignore_visibility and is not petrified.
---@param unit unit
---@param side? integer Side number, defaults to current side.
---@param cfg? ai_helper_visibility_opts
---@return boolean
function ai_helper.is_attackable_enemy(unit, side, cfg)
side = side or wesnoth.current.side
local viewing_side = cfg and cfg.viewing_side or side
ai_helper.check_viewing_side(viewing_side)
local ignore_visibility = cfg and cfg.ignore_visibility
if (not unit)
or (not wesnoth.sides.is_enemy(side, unit.side))
or unit.status.petrified
or ((not ignore_visibility) and (not ai_helper.is_visible_unit(viewing_side, unit)))
then
return false
end
return true
end
---Return the enemy closest to the specified location and its distance from the location, or to the
---leader of the specified side if a location is not specified
---@param loc? location
---@param side? integer Number of side for which to find enemy; defaults to current side
---@param cfg? get_enemies_opts
---@return nil
---@return number
function ai_helper.get_closest_enemy(loc, side, cfg)
side = side or wesnoth.current.side
local enemies = ai_helper.get_attackable_enemies({}, side, cfg)
local x, y
if not loc then
local leader = wesnoth.units.find_on_map { side = side, canrecruit = 'yes' }[1]
x, y = leader.x, leader.y
else
x, y = loc[1], loc[2]
end
local closest_distance, closest_enemy = math.huge, nil
for _,enemy in ipairs(enemies) do
local enemy_distance = M.distance_between(x, y, enemy)
if (enemy_distance < closest_distance) then
closest_enemy = enemy
closest_distance = enemy_distance
end
end
return closest_enemy, closest_distance
end
---Get the cost of the cheapest unit that can be recruited by the current side.
---@param leader unit if given, find the cheapest recruit cost for this leader, otherwise for the combination of all leaders of the current side
---@return number
function ai_helper.get_cheapest_recruit_cost(leader)
local recruit_ids = {}
for _,recruit_id in ipairs(wesnoth.sides[wesnoth.current.side].recruit) do
table.insert(recruit_ids, recruit_id)
end
local leaders
if leader then
leaders = { leader }
else
leaders = wesnoth.units.find_on_map { side = wesnoth.current.side, canrecruit = 'yes' }
end
for _,l in ipairs(leaders) do
for _,recruit_id in ipairs(l.extra_recruit) do
table.insert(recruit_ids, recruit_id)
end
end
local cheapest_unit_cost = math.huge
for _,recruit_id in ipairs(recruit_ids) do
if wesnoth.unit_types[recruit_id].cost < cheapest_unit_cost then
cheapest_unit_cost = wesnoth.unit_types[recruit_id].cost
end
end
return cheapest_unit_cost
end
--------- Move related helper functions ----------
ai_helper.no_path = 42424242 -- Value returned by C++ engine for distance when no path is found
---@class dst_src_opts : reach_options
---@field moves? "'max'"|"'current'" If set to 'max', unit MP is set to max_moves before calculation
---Get the dst_src location set for the specified units.
---@param units unit[]
---@param cfg? dst_src_opts
---@return location_set
function ai_helper.get_dst_src_units(units, cfg)
local max_moves = false
if cfg then
if (cfg['moves'] == 'max') then max_moves = true end
end
local dstsrc = LS.create()
for _,unit in ipairs(units) do
local tmp_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 = tmp_moves
end
for _,loc in ipairs(reach) do
local tmp_dst = dstsrc:get(loc) or {}
table.insert(tmp_dst, { x = unit.x, y = unit.y })
dstsrc:insert(loc, tmp_dst)
end
end
return dstsrc
end
---Get the dst_src location set for the specified units.
---If the units table is not given, use all units on the curent side.
---@param units? unit[]
---@param cfg? dst_src_opts
---@return location_set
function ai_helper.get_dst_src(units, cfg)
if (not units) then
units = wesnoth.units.find_on_map { side = wesnoth.current.side }
end
return ai_helper.get_dst_src_units(units, cfg)
end
---Get the dst_src location set for the specified enemy units.
---If the units table is not given, use all units on any enemy side.
---@param enemies? unit[]
---@param cfg? reach_options
---@return location_set
function ai_helper.get_enemy_dst_src(enemies, cfg)
if (not enemies) then
enemies = wesnoth.units.find_on_map {
wml.tag.filter_side { wml.tag.enemy_of { side = wesnoth.current.side} }
}
end
---@type dst_src_opts
local cfg_copy = {}
if cfg then cfg_copy = ai_helper.table_copy(cfg) end
cfg_copy.moves = 'max'
return ai_helper.get_dst_src_units(enemies, cfg_copy)
end
---@class ai_move
---@field src location
---@field dst location
---Find all possible moves the current side can make
---@return ai_move[]
function ai_helper.my_moves()
-- Produces an array with each field of form:
-- [1] = { dst = { x = 7, y = 16 },
-- src = { x = 6, y = 16 } }
local dstsrc = ai.get_dst_src()
---@type ai_move[]
local my_moves = {}
for key,value in pairs(dstsrc) do
local hex_x, hex_y = ai_helper.get_LS_xy(key)
table.insert( my_moves,
{ src = { x = value[1].x , y = value[1].y },
dst = { x = hex_x , y = hex_y }
}
)
end
return my_moves
end
---Find all possible moves the enemy sides can make
---@return ai_move[]
function ai_helper.enemy_moves()
-- Produces an array with each field of form:
-- [1] = { dst = { x = 7, y = 16 },
-- src = { x = 6, y = 16 } }
local dstsrc = ai.get_enemy_dst_src()
local enemy_moves = {}
for key,value in pairs(dstsrc) do
local hex_x, hex_y = ai_helper.get_LS_xy(key)
table.insert( enemy_moves,
{ src = { x = value[1].x , y = value[1].y },
dst = { x = hex_x , y = hex_y }
}
)
end
return enemy_moves
end
---@class ai_next_hop_opts : shrouded_path_opts, reachmap_opts
---@field ignore_own_units? boolean If set to true, then own units that can move out of the way are ignored.
---@field path? location[] If given, find the next hop along this path, rather than doing new path finding In this case, it is assumed that the path is possible, in other words, that cost has been checked.
---@field fan_out? boolean Prior to Wesnoth 1.16, the unit strictly followed the path, which can lead to a line-up of units if there are allied units in the way (e.g. when multiple units are moved by the same candidate action. Now they fan out instead, trying to get as close to the ideal next_hop goal (defined as where the unit could get if there were no allied units in the way) as possible. Setting 'fan_out=false' restores the old behavior. The main disadvantage of the new method is that it needs to do more path finding and therefore takes longer.
---Finds the next "hop" of @unit on its way to (@x,@y)
---Returns coordinates of the endpoint of the hop (or nil if no path to
---(x,y) is found for the unit), and movement cost to get there.
---Only unoccupied hexes are considered
---@param unit unit The unit who's moving
---@param x integer Destination X coordinate
---@param y integer Destination Y coordinate
---@param cfg? ai_next_hop_opts Extra options, most of which are passed through to wesnoth.paths.find_path()
---@return location?
---@return integer
function ai_helper.next_hop(unit, x, y, cfg)
local viewing_side = cfg and cfg.viewing_side or unit.side
ai_helper.check_viewing_side(viewing_side)
local ignore_visibility = cfg and cfg.ignore_visibility
local path, cost
if cfg and cfg.path then
path = cfg.path
else
path, cost = ai_helper.find_path_with_shroud(unit, x, y, cfg)
if cost >= ai_helper.no_path then return nil, cost end
end
-- If none of the hexes are unoccupied, use current position as default
local next_hop, nh_cost = unit.loc, 0
local next_hop_ideal = unit.loc
-- Go through loop to find reachable, unoccupied hex along the path
-- Start at second index, as first is just the unit position itself
for i = 2,#path do
if (not cfg) or (not cfg.avoid_map) or (not cfg.avoid_map:get(path[i])) then
local sub_path, sub_cost = ai_helper.find_path_with_shroud(unit, path[i].x, path[i].y, cfg)
if sub_cost <= unit.moves then
-- Check for unit in way only if cfg.ignore_units is not set
local unit_in_way
if (not cfg) or (not cfg.ignore_units) then
unit_in_way = wesnoth.units.get(path[i])
if unit_in_way and (not ignore_visibility) and (not ai_helper.is_visible_unit(viewing_side, unit_in_way)) then
unit_in_way = nil
end
-- If ignore_own_units is set, ignore own side units that can move out of the way
if cfg and cfg.ignore_own_units then
if unit_in_way and (unit_in_way.side == unit.side) then
local reach = ai_helper.get_reachable_unocc(unit_in_way, cfg)
if (reach:size() > 1) then unit_in_way = nil end
end
end
end
if not unit_in_way then
next_hop, nh_cost = path[i], sub_cost
end
next_hop_ideal = path[i]
else
break
end
end
end
local fan_out = cfg and cfg.fan_out
if (fan_out == nil) then fan_out = true end
if fan_out and ((next_hop.x ~= next_hop_ideal.x) or (next_hop.y ~= next_hop_ideal.y))
then
-- If we cannot get to the ideal next hop, try fanning out instead
local reach = wesnoth.paths.find_reach(unit, cfg)
-- Need the reach map of the unit from the ideal next hop hex
-- There will always be another unit there, otherwise we would not have gotten here
local unit_in_way = wesnoth.units.get(next_hop_ideal)
unit_in_way:extract()
local old_x, old_y = unit.x, unit.y
unit:extract()
unit:to_map(next_hop_ideal)
local inverse_reach = wesnoth.paths.find_reach(unit, { ignore_units = true }) -- no ZoC
unit:extract()
unit:to_map(old_x, old_y)
unit_in_way:to_map()
local terrain1 = wesnoth.current.map[next_hop_ideal]
local move_cost_endpoint = unit:movement_on(terrain1)
local inverse_reach_map = LS.create()
for _,r in pairs(inverse_reach) do
-- We want the moves left for moving into the opposite direction in which the reach map was calculated
local terrain2 = wesnoth.current.map[r]
local move_cost = unit:movement_on(terrain2)
local inverse_cost = r.moves_left + move_cost - move_cost_endpoint
inverse_reach_map:insert(r, inverse_cost)
end
local units
if ignore_visibility then
units = wesnoth.units.find_on_map({ wml.tag["not"] { id = unit.id } })
else
units = ai_helper.get_visible_units(viewing_side, { wml.tag["not"] { id = unit.id } })
end
local unit_map = LS.create()
for _,u in ipairs(units) do unit_map:insert(u.x, u.y, u.id) end
-- Do not move farther away, but if next_hop is out of reach from next_hop_ideal,
-- anything in reach is better -> set to -infinity in that case.
local max_rating = inverse_reach_map:get(next_hop) or - math.huge
for _,loc in ipairs(reach) do
if (not unit_map:get(loc))
and ((not cfg) or (not cfg.avoid_map) or (not cfg.avoid_map:get(loc)))
then
local rating = inverse_reach_map:get(loc) or - math.huge
if (rating > max_rating) then
max_rating = rating
next_hop.x, next_hop.y = loc.x, loc.y -- eliminating the third argument
end
end
end
end
return next_hop, nh_cost
end
---@class can_reach_opts : reachmap_opts, shrouded_path_opts
---Returns true if a hex is unoccupied (by a visible unit), or
---at most occupied by unit on same side as the seeking unit that can move away
---(can be modified with options below)
---@param unit unit
---@param x integer
---@param y integer
---@param cfg can_reach_opts
---@return boolean
function ai_helper.can_reach(unit, x, y, cfg)
cfg = cfg or {}
local viewing_side = cfg.viewing_side or unit.side
ai_helper.check_viewing_side(viewing_side)
local ignore_visibility = cfg and cfg.ignore_visibility
-- Is there a visible unit at the goal hex?
---@type unit?
local unit_in_way = wesnoth.units.get(x, y)
if unit_in_way and (not ignore_visibility) and (not ai_helper.is_visible_unit(viewing_side, unit_in_way)) then
unit_in_way = nil
end
if (cfg.exclude_occupied) and unit_in_way then
return false
end
-- Otherwise, if 'ignore_units' is not set, return false if there's a unit of other side,
-- or a unit of own side that cannot move away (this might be slow, don't know)
if (not cfg.ignore_units) then
-- If there's a unit at the goal that's not on own side (even ally), return false
if unit_in_way and (unit_in_way.side ~= unit.side) then
return false
end
-- If the unit in the way is on 'unit's' side and cannot move away, also return false
if unit_in_way and (unit_in_way.side == unit.side) then
-- need to pass the cfg here so that it works for enemy units (generally with no moves left) also
local move_away = ai_helper.get_reachable_unocc(unit_in_way, cfg)
if (move_away:size() <= 1) then return false end
end
end
-- After all that, test whether our unit can actually get there
local old_moves = unit.moves
if (cfg.moves == 'max') then unit.moves = unit.max_moves end
local can_reach = false
local path, cost = ai_helper.find_path_with_shroud(unit, x, y, cfg)
if (cost <= unit.moves) then can_reach = true end
unit.moves = old_moves
return can_reach
end
---@class reachmap_opts : dst_src_opts, ai_helper_visibility_opts
---@field exclude_occupied? boolean If true, exclude hexes that have units on them, irrespective of the value of ignore_units; defaults to false, in which case hexes with own units with moves > 0 are included
---@field avoid_map? location_set A location set with the hexes the unit is not allowed to step on.
---Get all reachable hexes for @unit that are actually available; that is,
---hexes that, at most, have own units on them which can move out of the way.
---By contrast, wesnoth.paths.find_reach also includes hexes with allied units on
---them, as well as own unit with no moves left.
---Returned array is a location set, with values set to remaining MP after the
---unit moves to the respective hexes.
---@param unit unit
---@param cfg reachmap_opts
---@return location_set
function ai_helper.get_reachmap(unit, cfg)
-- plus all other parameters to wesnoth.paths.find_reach
local viewing_side = cfg and cfg.viewing_side or unit.side
ai_helper.check_viewing_side(viewing_side)
local ignore_visibility = cfg and cfg.ignore_visibility
local old_moves = unit.moves
if cfg and (cfg.moves == 'max') then unit.moves = unit.max_moves end
local reachmap = LS.create()
local initial_reach = wesnoth.paths.find_reach(unit, cfg)
for _,loc in ipairs(initial_reach) do
local is_available = true
if cfg and cfg.avoid_map and cfg.avoid_map:get(loc) then
is_available = false
else
---@type unit?
local unit_in_way = wesnoth.units.get(loc)
if unit_in_way and (unit_in_way.id == unit.id) then
unit_in_way = nil
end
if unit_in_way and (not ignore_visibility) and (not ai_helper.is_visible_unit(viewing_side, unit_in_way)) then
unit_in_way = nil
end
if unit_in_way then
if cfg and cfg.exclude_occupied then
is_available = false
elseif (unit_in_way.side ~= unit.side) or (unit_in_way.moves == 0) then
is_available = false
end
end
end
if is_available then
reachmap:insert(loc, loc.moves_left)
end
end
unit.moves = old_moves
return reachmap
end
---Same as ai_helper.get_reachmap with exclude_occupied = true
---This function is now redundant, but we keep it for backward compatibility.
---@param unit unit
---@param cfg reachmap_opts
---@return location_set
function ai_helper.get_reachable_unocc(unit, cfg)
local cfg_GRU = cfg and ai_helper.table_copy(cfg) or {}
cfg_GRU.exclude_occupied = true
return ai_helper.get_reachmap(unit, cfg_GRU)
end
---@class shrouded_path_opts : path_options, ai_helper_visibility_opts
---Same as wesnoth.paths.find_path, just that it works under shroud as well while still ignoring invisible units. It does this by using ignore_visibility=true and taking invisible units off the map for the path finding process.
---@param unit unit The unit who wants to move.
---@param x integer Destination X coordinate.
---@param y integer Destination Y coordinate.
---@param cfg? shrouded_path_opts
---@return location[]
---@return integer
function ai_helper.find_path_with_shroud(unit, x, y, cfg)
-- Notes on some of the optional parameters that can be passed in @cfg:
-- - viewing_side: If not given, use side of the unit (not the current side!)
-- for determining which units are hidden and need to be extracted, as that
-- is what the path_finder code uses. If set to an invalid side, we can use
-- default path finding as shroud is ignored then anyway.
-- - ignore_visibility: see comments at beginning of this file. Defaults to nil.
-- This applies to the units only in this function, as it always ignores shroud.
-- - ignore_units: if true, hidden units do not need to be extracted because
-- all units are ignored anyway
local viewing_side = (cfg and cfg.viewing_side) or unit.side
ai_helper.check_viewing_side(viewing_side)
local ignore_visibility = cfg and cfg.ignore_visibility
local path, cost
if wesnoth.sides[viewing_side].shroud then
local extracted_units = {}
if (not cfg) or (not cfg.ignore_units) then
local all_units = wesnoth.units.find_on_map{}
for _,u in ipairs(all_units) do
if (u.id ~= unit.id) and (u.side ~= viewing_side)
and (not ignore_visibility) and (not ai_helper.is_visible_unit(viewing_side, u))
then
u:extract()
table.insert(extracted_units, u)
end
end
end
local cfg_copy = {}
if cfg then cfg_copy = ai_helper.table_copy(cfg) end
cfg_copy.ignore_visibility = true
wesnoth.interface.handle_user_interact()
path, cost = wesnoth.paths.find_path(unit, x, y, cfg_copy)
for _,extracted_unit in ipairs(extracted_units) do
extracted_unit:to_map()
end
else
wesnoth.interface.handle_user_interact()
path, cost = wesnoth.paths.find_path(unit, x, y, cfg)
end
return path, cost
end
---Custom cost function for path finding which takes hexes to be avoided into account.
---See the notes in function ai_helper.find_path_with_avoid()
---
---For efficiency reasons, this function requires quite a few arguments to be passed to it.
---Function ai_helper.find_path_with_avoid() does most of this automatically, but the custom cost
---function can be accessed directly also for even more customized behavior.
---@param x integer
---@param y integer
---@param prev_cost integer
---@param unit unit
---@param avoid_map location_set
---@param ally_map location_set
---@param enemy_map location_set
---@param enemy_zoc_map location_set
---@param strict_avoid boolean
---@return integer
function ai_helper.custom_cost_with_avoid(x, y, prev_cost, unit, avoid_map, ally_map, enemy_map, enemy_zoc_map, strict_avoid)
if enemy_map and enemy_map:get(x, y) then
return ai_helper.no_path
end
if strict_avoid and avoid_map and avoid_map:get(x, y) then
return ai_helper.no_path
end
local max_moves = unit.max_moves
local terrain = wesnoth.current.map[{x, y}]
local move_cost = unit:movement_on(terrain)
if (move_cost > max_moves) then
return ai_helper.no_path
end
local prev_moves = math.floor(prev_cost) -- remove all the minor ratings
-- Note that prev_moves_left == max_moves if the unit ended turn on previous hex, as it should
local prev_moves_left = max_moves - (unit.max_moves - unit.moves + prev_moves) % max_moves
if enemy_zoc_map and enemy_zoc_map:get(x,y) then
if (move_cost < prev_moves_left) then
move_cost = prev_moves_left
end
end
local moves_left = prev_moves_left - move_cost
-- Determine whether previous hex was marked as unusable for ending the turn on (in the ones' place
-- after multiplying by 100000), and also how many allied units are lines up along the path (tenth' place)
-- Working with large integers for this part, in order to prevent rounding errors
local prev_cost_int = math.floor(prev_cost * 100000 + 0.001)
local unit_penalty = math.floor((prev_cost * 100000 - prev_cost_int + 0.001) * 10) / 10
local avoid_penalty = math.floor(prev_cost_int - math.floor(prev_cost_int / 10) * 10 + 0.001)
local move_cost_int = math.floor(move_cost * 100000 + 0.001)
-- Apply unit_penalty only for the first turn
local is_first_turn = false
if (prev_moves < unit.moves) then is_first_turn = true end
if is_first_turn then
-- If the hex is both not-avoided and does not have a unit on it, we clear unit_penalty.
-- Otherwise we add in the move cost of the current hex.
-- The purpose of this is to have units spread out rather than move in a line, but note
-- that this only works between paths to the hex that use up the same movement cost.
-- It is fundamentally impossible with the Wesnoth A* search algorithm to make the unit
-- choose a longer path in this way.
if (ally_map and ally_map:get(x, y)) or (avoid_map and avoid_map:get(x, y)) then
unit_penalty = unit_penalty + move_cost / 10
-- We restrict this two 9 MP, even for units with more moves
if (unit_penalty > 0.9) then unit_penalty = 0.9 end
move_cost_int = move_cost_int + unit_penalty
else
move_cost_int = move_cost_int - unit_penalty
unit_penalty = 0
end
end
if (moves_left < 0) then
-- This is the situation when there were moves left on the previous hex,
-- but not enough to enter this hex. In this case, we need to apply the appropriate penalty:
-- - If avoided hex: disqualify it
-- - Otherwise use up full move on previous hex
-- - Also, apply the unit line-up penalty, but only if this is the first move
if (avoid_penalty > 0) then -- avoided hex
return ai_helper.no_path
end
move_cost_int = move_cost_int + prev_moves_left * 100000
if is_first_turn then
move_cost_int = move_cost_int + unit_penalty * 10 * 100000 -- unit_penalty is multiples of 0.1
end
elseif (moves_left == 0) then
-- And this is the case when moving to this hex uses up all moves for the turn
if avoid_map and avoid_map:get(x, y) then
return ai_helper.no_path
end
if is_first_turn then
move_cost_int = move_cost_int + unit_penalty * 10 * 100000 -- unit_penalty is multiples of 0.1
end
end
-- Here's the part that marks the hex as (un)usable
-- We first need to subtract out the previous penalty
move_cost_int = move_cost_int - avoid_penalty
-- Then we need to add in a small number (remember everything is divided by 100000 at the end)
-- because the move cost returned by this functions needs to be >= 1. Use defense for this,
-- thus giving a small bonus (low resulting move cost) for good terrain.
-- Note that the returned cost is rounded to an integer by the engine, so for very long paths this
-- will potentially add to the cost and might make the path inaccurate. However, for an average
-- defense of 50 along the path, this will not happen until the path is 1000 hexes long. Also,
-- in most cases this will simply add to the cost, rather than change the path itself.
local defense = unit:defense_on(terrain)
-- We need this to be multiples of 10 for the penalty identification to work
defense = mathx.round(defense / 10) * 10
if (defense > 90) then defense = 90 end
if (defense < 10) then defense = 10 end
move_cost_int = move_cost_int + (100 - defense)
-- And finally we add a (very small) penalty for this hex if it is to be avoided
-- This is used for the next hex to determine whether the previous hex was to be
-- avoided via avoid_penalty above.
if avoid_map and avoid_map:get(x, y) then
move_cost_int = move_cost_int + 1
end
return move_cost_int / 100000
end
---@class path_with_avoid_opts
---@field strict_avoid? boolean If 'true', trigger the "strict avoid" mode described above
---@field ignore_enemies? boolean If 'true', enemies will not be taken into account.
---@field ignore_allies? boolean If 'true', allied units will not be taken into account.
---Find path while taking hexes to be avoided into account. In its default setting,
-- it also finds the path so that the unit does not end a move on a hex with an allied
-- unit, which is one of the main shortcomings of the default path finder.
--
-- Important notes:
-- - There are two modes of avoiding hexes: the default for which the unit may move through
-- the avoided area but not end a move on it; and a "strict avoid" mode for which the
-- path may not lead through the avoided area at all.
-- - Not ending turns on hexes with allied units is meant to with units moving around each other,
-- but this can cause problems in narrow passages. It can therefore also be turned off.
-- - This cost function does not provide all the configurability of the default path finder.
-- The functionality is as follows:
-- - Hexes with visible enemy units are always excluded, and enemy ZoC is taken into account
-- - Invisible enemies are always ignored (including those under shroud)
-- - Hexes with higher terrain defense are preferred, all else being equal.
---@param unit unit
---@param x integer
---@param y integer
---@param avoid_map location_set
---@param options path_with_avoid_opts
---@return nil
---@return integer
function ai_helper.find_path_with_avoid(unit, x, y, avoid_map, options)
options = options or {}
-- This needs to be done separately, otherwise a path that only goes a short time into the
-- avoided area might not be disqualified correctly. It also saves evaluation time in other cases.
if avoid_map:get(x,y) then
return nil, ai_helper.no_path
end
local all_units = wesnoth.units.find_on_map{}
local ally_map, enemy_map = LS.create(), LS.create()
for _,u in ipairs(all_units) do
if (u.id ~= unit.id) and ai_helper.is_visible_unit(wesnoth.current.side, u) then
if wesnoth.sides.is_enemy(u.side, wesnoth.current.side) then
if (not options.ignore_enemies) then
enemy_map:insert(u.x, u.y, u.level)
end
else
if (not options.ignore_allies) then
ally_map:insert(u.x, u.y, u.level)
end
end
end
end
local enemy_zoc_map = LS.create()
if (not options.ignore_enemies) and (not unit:ability("skirmisher")) then
enemy_map:iter(function(xx, yy, level)
if (level > 0) then
for xa,ya in wesnoth.current.map:iter_adjacent(xx, yy) do
enemy_zoc_map:insert(xa, ya, level)
end
end
end)
end
-- Note: even though the cost function returns a float, the engine rounds the cost to an integer
return ai_helper.find_path_with_shroud(unit, x, y, {
calculate = function(xc, yc, current_cost)
return ai_helper.custom_cost_with_avoid(xc, yc, current_cost, unit, avoid_map, ally_map, enemy_map, enemy_zoc_map, options.strict_avoid)
end
})
end
---@class best_move_opts : reachmap_opts
---@field labels? boolean If set, put labels with ratings onto map
---@field no_random? boolean If set, do not add random value between 0.0001 and 0.0099 to each hex
---Find the best move and best unit based on a rating_function
---@param units unit|unit[] single unit or list of units
---@param rating_function fun(x:integer, y:integer):integer rating function for the hexes the unit can reach
---@param cfg any
---@return location? #best_hex - nil if no valid moves were found
---@return unit? #best_unit - unit for which this rating function produced the maximum value; nil if no valid moves were found
---@return number #max_rating - the rating found for this hex/unit combination
function ai_helper.find_best_move(units, rating_function, cfg)
cfg = cfg or {}
-- If this is an individual unit, turn it into an array
if units.hitpoints then units = { units } end
local max_rating, best_hex, best_unit = - math.huge, nil, nil
for _,unit in ipairs(units) do
-- Hexes each unit can reach
local reach_map = ai_helper.get_reachable_unocc(unit, cfg)
reach_map:iter( function(x, y, v)
-- Rate based on rating_function argument
local rating = rating_function(x, y)
-- If cfg.random is set, add some randomness (on 0.0001 - 0.0099 level)
if (not cfg.no_random) then rating = rating + math.random(99) / 10000. end
-- If cfg.labels is set: insert values for label map
if cfg.labels then reach_map:insert(x, y, rating) end
if rating > max_rating then
max_rating, best_hex, best_unit = rating, { x, y }, unit
end
end)
if cfg.labels then ai_helper.put_labels(reach_map) end
end
return best_hex, best_unit, max_rating
end
---@class move_out_of_way_opts : reach_options, ai_helper_visibility_opts
---@field dx? integer The direction in which moving out of the way is preferred
---@field dy? integer The direction in which moving out of the way is preferred
---@field avoid_map? location_set If given, the hexes in avoid_map are excluded
---@field labels? boolean if set, display labels of the rating for each hex the unit can reach
---Move aunit to the best close location.
---Main rating is the moves the unit still has left after that
---@param ai ailib
---@param unit unit
---@param cfg move_out_of_way_opts
function ai_helper.move_unit_out_of_way(ai, unit, cfg)
cfg = cfg or {}
local viewing_side = cfg.viewing_side or unit.side
ai_helper.check_viewing_side(viewing_side)
local ignore_visibility = cfg and cfg.ignore_visibility
local dx, dy
if cfg.dx and cfg.dy then
local r = math.sqrt(cfg.dx * cfg.dx + cfg.dy * cfg.dy)
if (r ~= 0) then dx, dy = cfg.dx / r, cfg.dy / r end
end
local reach = wesnoth.paths.find_reach(unit, cfg)
local reach_map = LS.create()
local max_rating = - math.huge
---@type location?
local best_hex = nil
for _,loc in ipairs(reach) do
local unit_in_way = wesnoth.units.get(loc)
if (not unit_in_way) -- also excludes current hex
or ((not ignore_visibility) and (not ai_helper.is_visible_unit(viewing_side, unit_in_way)))
then
local avoid_this_hex = cfg and cfg.avoid_map and cfg.avoid_map:get(loc)
if (not avoid_this_hex) then
local rating = loc.moves_left -- also disfavors hexes next to visible enemy units for which loc[3] = 0
if dx then
rating = rating + (loc.x - unit.x) * dx * 0.01
rating = rating + (loc.y - unit.y) * dy * 0.01
end
if cfg.labels then reach_map:insert(loc, rating) end
if (rating > max_rating) then
max_rating, best_hex = rating, loc
end
end
end
end
if cfg.labels then ai_helper.put_labels(reach_map) end
if best_hex then
ai_helper.checked_move(ai, unit, best_hex.x, best_hex.y)
end
end
---Does ai.move_full for the unit if not at the specified location, otherwise ai.stopunit_moves
---Uses ai_helper.next_hop(), so that it works if unit cannot get there in one move
---@param ai ailib
---@param unit unit
---@param x integer
---@param y integer
---@return ai_result
---@overload fun(ai:ailib, unit:unit, loc:location):ai_result
function ai_helper.movefull_stopunit(ai, unit, x, y)
if (type(x) ~= 'number') then
if x[1] then
x, y = x[1], x[2]
else
x, y = x.x, x.y
end
end
-- TODO: Use read_location above (and in many other places as well)
local next_hop = ai_helper.next_hop(unit, x, y)
if next_hop and ((next_hop[1] ~= unit.x) or (next_hop[2] ~= unit.y)) then
return ai_helper.checked_move_full(ai, unit, next_hop[1], next_hop[2])
else
return ai_helper.checked_stopunit_moves(ai, unit)
end
end
---@class full_move_opts : shrouded_path_opts, move_out_of_way_opts
---Same as ai_help.movefull_stopunit(), but also moves a unit out of the way if there is one
---@param ai ailib
---@param unit unit
---@param x integer
---@param y integer
---@param cfg? full_move_opts
---@overload fun(ai:ailib, unit:unit, loc:location, cfg?:full_move_opts)
function ai_helper.movefull_outofway_stopunit(ai, unit, x, y, cfg)
local viewing_side = cfg and cfg.viewing_side or unit.side
ai_helper.check_viewing_side(viewing_side)
local ignore_visibility = cfg and cfg.ignore_visibility
if (type(x) ~= 'number') then
if x[1] then
x, y = x[1], x[2]
else
x, y = x.x, x.y
end
end
-- Only move unit out of way if the main unit can get there
local path, cost = ai_helper.find_path_with_shroud(unit, x, y, cfg)
if (cost <= unit.moves) then
local unit_in_way = wesnoth.units.get(x, y)
if unit_in_way and (unit_in_way ~= unit)
and (ignore_visibility or ai_helper.is_visible_unit(viewing_side, unit_in_way))
then
ai_helper.move_unit_out_of_way(ai, unit_in_way, cfg)
end
end
local next_hop = ai_helper.next_hop(unit, x, y)
if next_hop and ((next_hop[1] ~= unit.x) or (next_hop[2] ~= unit.y)) then
ai_helper.checked_move_full(ai, unit, next_hop[1], next_hop[2])
else
ai_helper.checked_stopunit_moves(ai, unit)
end
end
---------- Attack related helper functions --------------
---@class get_attacks_opts : dst_src_opts, ai_helper_visibility_opts
---@field include_occupied? boolean If set, also include hexes occupied by own-side units that can move away
---@field simulate_combat? boolean If set, also simulate the combat and return result (this is slow; only set if needed)
---@class ai_attack
---@field src location The location of the unit that can attack.
---@field dst location The location of the hex the unit should attack from.
---@field target location The location of the defending unit to be attacked.
---@field att_stats stats_evaluation?
---@field def_stats stats_evaluation?
---@field attack_hex_occupied boolean Whether an own unit that can move away is on the attack hex (dst).
---Get all attacks the specified units can do. Enemies invisible to the side
-- of those units are excluded, unless the option cfg.ignore_visibility=true is used.
---@param units unit[]
---@param cfg get_attacks_opts
---@return ai_attack[]
function ai_helper.get_attacks(units, cfg)
cfg = cfg or {}
---@type ai_attack[]
local attacks = {}
if (not units[1]) then return attacks end
local side = units[1].side -- all units need to be on same side
local ignore_visibility = cfg and cfg.ignore_visibility
-- 'moves' can be either "current" or "max"
-- For unit on current side: use "current" by default, or override by cfg.moves
local moves = cfg.moves or "current"
-- For unit on any other side, only moves="max" makes sense
if (side ~= wesnoth.current.side) then moves = "max" end
local old_moves = {}
if (moves == "max") then
for i,unit in ipairs(units) do
old_moves[i] = unit.moves
unit.moves = unit.max_moves
end
end
-- Note: the remainder is optimized for speed, so we only get_units once,
-- do not use WML filters, etc.
local all_units = wesnoth.units.find_on_map{}
local enemy_map, my_unit_map, other_unit_map = LS.create(), LS.create(), LS.create()
for i,unit in ipairs(all_units) do
-- The value of all the location sets is the index of the
-- unit in the all_units array
if ai_helper.is_attackable_enemy(unit, side, cfg) then
enemy_map:insert(unit.x, unit.y, i)
end
if (unit.side == side) then
my_unit_map:insert(unit.x, unit.y, i)
else
if ignore_visibility or ai_helper.is_visible_unit(side, unit) then
other_unit_map:insert(unit.x, unit.y, i)
end
end
end
local attack_hex_map = LS.create()
enemy_map:iter(function(e_x, e_y, i)
for xa,ya in wesnoth.current.map:iter_adjacent(e_x, e_y) do
-- If there's no unit of another side on this hex, include it
-- as possible attack location (this includes hexes occupied
-- by own units at this time)
if (not other_unit_map:get(xa, ya)) then
local target_table = attack_hex_map:get(xa, ya) or {}
table.insert(target_table, { x = e_x, y = e_y, i = i })
attack_hex_map:insert(xa, ya, target_table)
end
end
end)
-- The following is done so that we at most need to do find_reach() once per unit
-- It is needed for all units in @units and for testing whether units can move out of the way
local reaches = LS.create()
for _,unit in ipairs(units) do
wesnoth.interface.handle_user_interact()
local reach
if reaches:get(unit) then
reach = reaches:get(unit)
else
reach = wesnoth.paths.find_reach(unit, cfg)
reaches:insert(unit, reach)
end
for _,loc in ipairs(reach) do
if attack_hex_map:get(loc) then
local add_target = true
local attack_hex_occupied = false
-- If another unit of same side is on this hex:
if my_unit_map:get(loc) and ((loc.x ~= unit.x) or (loc.y ~= unit.y)) then
attack_hex_occupied = true
add_target = false
if cfg.include_occupied then -- Test whether it can move out of the way
local unit_in_way = all_units[my_unit_map:get(loc)]
local uiw_reach
if reaches:get(unit_in_way) then
uiw_reach = reaches:get(unit_in_way)
else
uiw_reach = wesnoth.paths.find_reach(unit_in_way, cfg)
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.).
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)
if (not uiw_uiw)
or ((not ignore_visibility) and (not ai_helper.is_visible_unit(side, uiw_uiw)))
then
add_target = true
break
end
end
end
end
if add_target then
for _,target in ipairs(attack_hex_map:get(loc)) do
local att_stats, def_stats
if cfg.simulate_combat then
local unit_dst = unit:clone()
unit_dst.x, unit_dst.y = loc.x, loc.y
local enemy = all_units[target.i]
att_stats, def_stats = wesnoth.simulate_combat(unit_dst, enemy)
end
table.insert(attacks, {
src = { x = unit.x, y = unit.y },
dst = { x = loc.x, y = loc.y },
target = { x = target.x, y = target.y },
att_stats = att_stats,
def_stats = def_stats,
attack_hex_occupied = attack_hex_occupied
})
end
end
end
end
end
if (moves == "max") then
for i,unit in ipairs(units) do
unit.moves = old_moves[i]
end
end
return attacks
end
---This is called from ai_helper.get_attack_combos_full() and
---builds up the combos for the next recursion level.
---It also calls the next recursion level, if possible
---@param combos table?
---@param attacks ai_attack[]
---@return table<integer, integer>[]
local function add_next_attack_combo_level(combos, attacks)
-- Important: function needs to make a copy of the input array, otherwise original is changed
-- Set up the array, if this is the first recursion level
if (not combos) then combos = {} end
-- Array to hold combinations for this recursion level only
local combos_this_level = {}
for _,attack in ipairs(attacks) do
local dst = attack.dst.y + attack.dst.x * 1000. -- attack hex (src)
local src = attack.src.y + attack.src.x * 1000. -- attacker hex (dst)
if (not combos[1]) then -- if this is the first recursion level, set up new combos for this level
local move = {}
move[dst] = src
table.insert(combos_this_level, move)
else
-- Otherwise, we need to go through the already existing elements in 'combos'
-- to see if either hex, or attacker is already used; and then add new attack to each
for _,combo in ipairs(combos) do
local this_combo = {} -- needed because tables are pointers, need to create a separate one
local add_combo = true
for d,s in pairs(combo) do
if (d == dst) or (s == src) then
add_combo = false
break
end
this_combo[d] = s -- insert individual moves to a combo
end
if add_combo then -- and add it into the array, if it contains only unique moves
this_combo[dst] = src
table.insert(combos_this_level, this_combo)
end
end
end
end
local combos_next_level = {}
if combos_this_level[1] then -- If moves were found for this level, also find those for the next level
combos_next_level = add_next_attack_combo_level(combos_this_level, attacks)
end
-- Finally, combine this level and next level combos
combos_this_level = ai_helper.array_merge(combos_this_level, combos_next_level)
return combos_this_level
end
---Calculate attack combination result by @units on @enemy
---All combinations of all units are taken into account, as well as their order
---This can result in a _very_ large number of possible combinations
---Use ai_helper.get_attack_combos() instead if order does not matter
---@param units unit[]
---@param enemy unit
---@param cfg get_attacks_opts
---@return table<integer, integer>[]
function ai_helper.get_attack_combos_full(units, enemy, cfg)
local all_attacks = ai_helper.get_attacks(units, cfg)
-- Eliminate those that are not on @enemy
local attacks = {}
for _,attack in ipairs(all_attacks) do
if (attack.target.x == enemy.x) and (attack.target.y == enemy.y) then
table.insert(attacks, attack)
end
end
if (not attacks[1]) then return {} end
-- This recursive function does all the work:
local combos = add_next_attack_combo_level(nil, attacks)
return combos
end
---Calculate attack combination result by @units on @enemy
---All the unit/hex combinations are considered, but without specifying the order of the
---attacks. Use ai_helper.get_attack_combos_full() if order matters.
---@param units unit[]
---@param enemy unit
---@param cfg shrouded_path_opts
---@return table<integer, integer>[]
---@return table<integer, integer[]>
function ai_helper.get_attack_combos(units, enemy, cfg)
-- Return values:
-- 1. Attack combinations in form { dst = src }
-- 2. All the attacks indexed by [dst][src]
-- We don't need the full attacks here, just the coordinates,
-- so for speed reasons, we do not use ai_helper.get_attacks()
-- For units on the current side, we need to make sure that
-- there isn't a unit in the way that cannot move any more
-- TODO: generalize it so that it works not only for units with moves=0, but blocked units etc.
local blocked_hexes = LS.create()
if units[1] and (units[1].side == wesnoth.current.side) then
local all_units = wesnoth.units.find_on_map { side = wesnoth.current.side }
for _,unit in ipairs(all_units) do
if (unit.moves == 0) then
blocked_hexes:insert(unit.x, unit.y)
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
-- Find which units in @units can get to hexes next to the enemy
local attacks_dst_src = {}
local found_attacks = false
for xa,ya in wesnoth.current.map:iter_adjacent(enemy) do
-- Make sure the hex is not occupied by unit that cannot move out of the way
local dst = xa * 1000 + ya
for _,unit in ipairs(units) do
if ((unit.x == xa) and (unit.y == ya)) or (not blocked_hexes:get(xa, ya)) then
-- 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 = ai_helper.find_path_with_shroud(unit, xa, ya, cfg)
end
if (cost <= unit.moves) then
-- for attack by no unit on this hex
if (not attacks_dst_src[dst]) then
attacks_dst_src[dst] = { 0, unit.x * 1000 + unit.y }
found_attacks = true -- since attacks_dst_src is not a simple array, this is easier
else
table.insert(attacks_dst_src[dst], unit.x * 1000 + unit.y )
end
end
end
end
end
for i,unit in ipairs(units) do
if (unit.side ~= wesnoth.current.side) then
unit.moves = old_moves[i]
end
end
if (not found_attacks) then return {}, {} end
-- Now we set up an array of all attack combinations
-- at this time, this includes all the 'no unit attacks this hex' elements
-- which have a value of 0 for 'src'
-- They need to be kept in this part, so that we get the combos that do not
-- use the maximum amount of units possible. They will be eliminated below.
local attack_array = {}
-- For all values of 'dst'
for dst,ads in pairs(attacks_dst_src) do
local org_array = ai_helper.table_copy(attack_array)
attack_array = {}
-- Go through all the values of 'src'
for _,src in ipairs(ads) do
-- If the array does not exist, set it up
if (not org_array[1]) then
local tmp = {}
tmp[dst] = src
table.insert(attack_array, tmp)
else -- otherwise, add the new dst-src pair to each element of the existing array
for _,org in ipairs(org_array) do
-- but only do so if that 'src' value does not exist already
-- except for 0's those all need to be kept
local add_attack = true
for _,s in pairs(org) do
if (s == src) and (src ~=0) then
add_attack = false
break
end
end
-- Finally, add it to the array
if add_attack then
local tmp = ai_helper.table_copy(org)
tmp[dst] = src
table.insert(attack_array, tmp)
end
end
end
end
end
-- Now eliminate all the 0s
-- Also eliminate the combo that has no attacks on any hex (all zeros)
local i_empty = 0
for i,att in ipairs(attack_array) do
local count = 0
for dst,src in pairs(att) do
if (src == 0) then
att[dst] = nil
else
count = count + 1
end
end
if (count == 0) then i_empty = i end
end
-- This last step eliminates the "empty attack combo" (the one with all zeros)
-- Since this is only one, it's okay to use table.remove (even though it's slow)
table.remove(attack_array, i_empty)
return attack_array, attacks_dst_src
end
---Get the damage multiplier for a given alignment and turn.
---@param alignment string The alignment to check.
---@param lawful_bonus integer The lawful bonus from the schedule for the desired turn.
---@return number
function ai_helper.get_unit_time_of_day_bonus(alignment, lawful_bonus)
local multiplier = 1
if (lawful_bonus ~= 0) then
if (alignment == 'lawful') then
multiplier = (1 + lawful_bonus / 100.)
elseif (alignment == 'chaotic') then
multiplier = (1 - lawful_bonus / 100.)
elseif (alignment == 'liminal') then
-- TODO: I think this is wrong?
multiplier = (1 - math.abs(lawful_bonus) / 100.)
end
end
return multiplier
end
return ai_helper