mirror of
https://github.com/wesnoth/wesnoth
synced 2025-05-03 21:39:44 +00:00

This is done for consistency with similar functions in mainline. In principle this breaks backward compatibility. However, while it is theoretically possible for this to happen, this function will generally always return at least the hex the unit is on, so it is very unlikely that this actually makes a difference somewhere.
1949 lines
73 KiB
Lua
1949 lines
73 KiB
Lua
local H = wesnoth.require "helper"
|
|
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' parameter):
|
|
-- 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' parameter, which works
|
|
-- in the same way as it does in wesnoth.find_reach() and wesnoth.find_path():
|
|
-- - If set to a valid side number, vision for that side is used
|
|
-- - If set to an invalid side number (e.g. 0), all units on the map are seen
|
|
-- - If omitted and a function takes a a parameter linked to a specific side,
|
|
-- such as a side number or a unit, as input, vision of that side is used. In
|
|
-- this case, viewing_side is passed as part of the optional @cfg configuration
|
|
-- table and can be passed from function to function.
|
|
-- - If omitted and the function takes no such input, viewing_side is made a
|
|
-- required parameter in order to avoid mismatches between the default values
|
|
-- of different functions.
|
|
--
|
|
-- 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.find_path().
|
|
|
|
local ai_helper = {}
|
|
|
|
----- Debugging helper functions ------
|
|
|
|
function ai_helper.show_messages()
|
|
-- Returns true or false (hard-coded). To be used to
|
|
-- show messages if in debug mode.
|
|
-- 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
|
|
|
|
function ai_helper.print_exec()
|
|
-- Returns true or false (hard-coded). To be used to
|
|
-- show which CA is being executed if in debug mode.
|
|
-- 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
|
|
|
|
function ai_helper.print_eval()
|
|
-- Returns true or false (hard-coded). To be used to
|
|
-- show which CA is being evaluated if in debug mode.
|
|
-- 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
|
|
|
|
function ai_helper.done_eval_messages(start_time, ca_name)
|
|
ca_name = ca_name or 'unknown'
|
|
local dt = wesnoth.get_time_stamp() / 1000. - start_time
|
|
if ai_helper.print_eval() then
|
|
ai_helper.print_ts_delta(start_time, ' - Done evaluating ' .. ca_name .. ':')
|
|
end
|
|
end
|
|
|
|
function ai_helper.clear_labels()
|
|
-- Clear all labels on a map
|
|
local width, height = wesnoth.get_map_size()
|
|
for x = 1,width do
|
|
for y = 1,height do
|
|
wesnoth.label { x = x, y = y, text = "" }
|
|
end
|
|
end
|
|
end
|
|
|
|
function ai_helper.put_labels(map, cfg)
|
|
-- Take @map (location set) and put label containing 'value' onto the map.
|
|
-- Print 'nan' if element exists but is not a number.
|
|
-- @cfg: table with optional configuration parameters:
|
|
-- - show_coords: (boolean) use hex coordinates as labels instead of value
|
|
-- - factor=1: (number) if value is a number, multiply by this factor
|
|
-- - keys: (array) 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 it's data.arg[3], set keys = { 'arg', 3 }
|
|
|
|
cfg = cfg or {}
|
|
local factor = cfg.factor or 1
|
|
|
|
ai_helper.clear_labels()
|
|
|
|
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
|
|
wesnoth.label { x = x, y = y, text = out }
|
|
end)
|
|
end
|
|
|
|
function ai_helper.print_ts(...)
|
|
-- Print arguments preceded by a time stamp in seconds
|
|
-- Also return that time stamp
|
|
|
|
local ts = wesnoth.get_time_stamp() / 1000.
|
|
|
|
local arg = { ... }
|
|
arg[#arg+1] = string.format('[ t = %.3f ]', ts)
|
|
|
|
std_print(table.unpack(arg))
|
|
|
|
return ts
|
|
end
|
|
|
|
function ai_helper.print_ts_delta(start_time, ...)
|
|
-- @start_time: time stamp in seconds as returned by wesnoth.get_time_stamp / 1000.
|
|
|
|
-- 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
|
|
|
|
local ts = wesnoth.get_time_stamp() / 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 ------
|
|
|
|
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
|
|
|
|
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
|
|
|
|
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
|
|
|
|
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
|
|
|
|
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
|
|
|
|
function ai_helper.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
|
|
|
|
function ai_helper.checked_move_full(ai, unit, x, y)
|
|
return ai_helper.checked_move_core(ai, unit, x, y, 'ai.move_full')
|
|
end
|
|
|
|
function ai_helper.checked_move(ai, unit, x, y)
|
|
return ai_helper.checked_move_core(ai, unit, x, y, 'ai.move')
|
|
end
|
|
|
|
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
|
|
|
|
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
|
|
|
|
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
|
|
|
|
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
|
|
|
|
function ai_helper.robust_move_and_attack(ai, src, dst, target_loc, cfg)
|
|
-- 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.
|
|
--
|
|
-- Required input parameters:
|
|
-- @ai: the Lua ai table
|
|
-- @src: current coordinates of the AI unit to be used
|
|
-- @dst: 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.
|
|
--
|
|
-- Optional parameters:
|
|
-- @target_loc: coordinates of the enemy unit to be attacked. If not given, no
|
|
-- attack is attempted.
|
|
-- @cfg: table with optional configuration parameters:
|
|
-- partial_move: By default, this function performs full moves. If this
|
|
-- parameter is true, a partial move is done instead.
|
|
-- weapon: The number (starting at 1) of the attack weapon to be used.
|
|
-- If omitted, the best weapon is automatically selected.
|
|
-- all optional parameters for ai_helper.move_unit_out_of_way()
|
|
|
|
-- Notes:
|
|
-- - @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.get_unit(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.get_unit(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.get_unit(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 = wesnoth.get_unit(dst_x, dst_y)
|
|
if unit_in_way 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 ------
|
|
|
|
function ai_helper.filter(input, condition)
|
|
return F.filter(input, condition)
|
|
end
|
|
|
|
function ai_helper.choose(input, value)
|
|
return F.choose(input, value)
|
|
end
|
|
|
|
function ai_helper.table_copy(t)
|
|
-- Make a copy of a table (rather than just another pointer to the same table)
|
|
local copy = {}
|
|
for k,v in pairs(t) do copy[k] = v end
|
|
return copy
|
|
end
|
|
|
|
function ai_helper.array_merge(a1, a2)
|
|
-- Merge two arrays without overwriting @a1 or @a2 -> create a new table
|
|
-- This only works with arrays, not general tables
|
|
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
|
|
|
|
function ai_helper.serialize(input)
|
|
-- Convert @input to a string in a format corresponding to the type of @input
|
|
-- The string is all put into one line
|
|
|
|
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
|
|
|
|
function ai_helper.split(str, sep)
|
|
-- Split string @str into a table using the delimiter @sep (default: ',')
|
|
|
|
local sep, fields = sep or ",", {}
|
|
local pattern = string.format("([^%s]+)", sep)
|
|
string.gsub(str, pattern, function(c) fields[#fields+1] = c end)
|
|
return fields
|
|
end
|
|
|
|
--------- Location set related helper functions ----------
|
|
|
|
function ai_helper.get_LS_xy(index)
|
|
-- 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
|
|
|
|
local tmp_set = LS.create()
|
|
tmp_set.values[index] = 1
|
|
local xy = tmp_set:to_pairs()[1]
|
|
|
|
return xy[1], xy[2]
|
|
end
|
|
|
|
--------- Location, position or hex related helper functions ----------
|
|
|
|
function ai_helper.cartesian_coords(x, y)
|
|
-- 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.
|
|
return x, y + ((x + 1) % 2) / 2.
|
|
end
|
|
|
|
function ai_helper.get_angle(from_hex, to_hex)
|
|
-- 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
|
|
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
|
|
|
|
function ai_helper.get_direction_index(from_hex, to_hex, n, center_on_east)
|
|
-- 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
|
|
--
|
|
-- Optional input:
|
|
-- @center_on_east (false): 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
|
|
|
|
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
|
|
|
|
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
|
|
|
|
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
|
|
|
|
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
|
|
|
|
function ai_helper.find_opposite_hex_adjacent(hex, center_hex)
|
|
-- Find the hex that is opposite of @hex with respect to @center_hex
|
|
-- Both input hexes are of format { x, y }
|
|
-- Output: {opp_x, opp_y} -- or nil if @hex and @center_hex are not adjacent
|
|
-- (or no opposite hex is found, e.g. for hexes on border)
|
|
|
|
-- If the two input hexes are not adjacent, return nil
|
|
if (M.distance_between(hex[1], hex[2], center_hex[1], center_hex[2]) ~= 1) then return nil end
|
|
|
|
-- Finding the opposite x position is easy
|
|
local opp_x = center_hex[1] + (center_hex[1] - hex[1])
|
|
|
|
-- 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 H.adjacent_tiles(center_hex[1], center_hex[2]) do
|
|
if (xa == opp_x) and (ya ~= hex[2]) then return { xa, ya } end
|
|
end
|
|
|
|
return nil
|
|
end
|
|
|
|
function ai_helper.find_opposite_hex(hex, center_hex)
|
|
-- 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!
|
|
-- Both input hexes are of format { x, y }
|
|
-- Output: { opp_x, opp_y }
|
|
|
|
-- Finding the opposite x position is easy
|
|
local opp_x = center_hex[1] + (center_hex[1] - hex[1])
|
|
|
|
-- Going to "square geometry" for y coordinate
|
|
local y_sq = hex[2] * 2 - (hex[1] % 2)
|
|
local yc_sq = center_hex[2] * 2 - (center_hex[1] % 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 {opp_x, opp_y}
|
|
end
|
|
|
|
function ai_helper.is_opposite_adjacent(hex1, hex2, center_hex)
|
|
-- Returns true if @hex1 and @hex2 are opposite from each other with respect to @center_hex
|
|
|
|
local opp_hex = ai_helper.find_opposite_hex_adjacent(hex1, center_hex)
|
|
|
|
if opp_hex and (opp_hex[1] == hex2[1]) and (opp_hex[2] == hex2[2]) then return true end
|
|
return false
|
|
end
|
|
|
|
function ai_helper.get_locations_no_borders(location_filter)
|
|
-- Returns the same locations array as wesnoth.get_locations(location_filter),
|
|
-- but excluding hexes on the map border.
|
|
--
|
|
-- This is faster than alternative methods, at least with the current
|
|
-- implementation of standard location filter evaluation by the engine.
|
|
-- Note that this might not work if @location_filter is a vconfig object.
|
|
|
|
local old_include_borders = location_filter.include_borders
|
|
location_filter.include_borders = false
|
|
local locs = wesnoth.get_locations(location_filter)
|
|
location_filter.include_borders = old_include_borders
|
|
return locs
|
|
end
|
|
|
|
function ai_helper.get_closest_location(hex, location_filter, unit)
|
|
-- Get the location closest to @hex (in format { x, y })
|
|
-- that matches @location_filter (in WML table format)
|
|
-- @unit can be passed as an optional third parameter, in which case the
|
|
-- terrain needs to be passable for that unit
|
|
-- Returns nil if no terrain matching the filter was found
|
|
|
|
-- Find the maximum distance from 'hex' that's possible on the map
|
|
local max_distance = 0
|
|
local width, height = wesnoth.get_map_size()
|
|
local to_top_left = M.distance_between(hex[1], hex[2], 0, 0)
|
|
if (to_top_left > max_distance) then max_distance = to_top_left end
|
|
local to_top_right = M.distance_between(hex[1], hex[2], width+1, 0)
|
|
if (to_top_right > max_distance) then max_distance = to_top_right end
|
|
local to_bottom_left = M.distance_between(hex[1], hex[2], 0, height+1)
|
|
if (to_bottom_left > max_distance) then max_distance = to_bottom_left end
|
|
local to_bottom_right = M.distance_between(hex[1], hex[2], width+1, height+1)
|
|
if (to_bottom_right > max_distance) then max_distance = to_bottom_right end
|
|
|
|
local radius = 0
|
|
while (radius <= max_distance) do
|
|
local loc_filter = {}
|
|
if (radius == 0) then
|
|
loc_filter = {
|
|
{ "and", { x = hex[1], y = hex[2], radius = radius } },
|
|
{ "and", location_filter }
|
|
}
|
|
else
|
|
loc_filter = {
|
|
{ "and", { x = hex[1], y = hex[2], radius = radius } },
|
|
{ "not", { x = hex[1], y = hex[2], radius = radius - 1 } },
|
|
{ "and", location_filter }
|
|
}
|
|
end
|
|
|
|
local locs = wesnoth.get_locations(loc_filter)
|
|
|
|
if unit then
|
|
for _,loc in ipairs(locs) do
|
|
local movecost = unit:movement(wesnoth.get_terrain(loc[1], loc[2]))
|
|
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
|
|
|
|
function ai_helper.get_passable_locations(location_filter, unit)
|
|
-- Finds all locations matching @location_filter that are passable for
|
|
-- @unit. This also excludes hexes on the map border.
|
|
-- @unit is optional: if omitted, all hexes matching the filter, but
|
|
-- excluding border hexes are returned
|
|
|
|
-- All hexes that are not on the map border
|
|
local all_locs = ai_helper.get_locations_no_borders(location_filter)
|
|
|
|
-- If @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(wesnoth.get_terrain(loc[1], loc[2]))
|
|
if (movecost <= unit.max_moves) then table.insert(locs, loc) end
|
|
end
|
|
return locs
|
|
end
|
|
|
|
return all_locs
|
|
end
|
|
|
|
function ai_helper.distance_map(units, map)
|
|
-- Get the distance map DM for all units in @units (as a location set)
|
|
-- DM = sum ( distance_from_unit )
|
|
-- This is done for all elements of @map (a locations set), or for the entire map if @map is not given
|
|
|
|
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, unit.y, x, y)
|
|
end
|
|
DM:insert(x, y, dist)
|
|
end)
|
|
else
|
|
local width, height = wesnoth.get_map_size()
|
|
for x = 1,width do
|
|
for y = 1,height do
|
|
local dist = 0
|
|
for _,unit in ipairs(units) do
|
|
dist = dist + M.distance_between(unit.x, unit.y, x, y)
|
|
end
|
|
DM:insert(x, y, dist)
|
|
end
|
|
end
|
|
end
|
|
|
|
return DM
|
|
end
|
|
|
|
function ai_helper.inverse_distance_map(units, map)
|
|
-- Get the inverse distance map IDM for all units in @units (as a location set)
|
|
-- IDM = sum ( 1 / (distance_from_unit+1) )
|
|
-- This is done for all elements of @map (a locations set), or for the entire map if @map is not given
|
|
|
|
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, unit.y, x, y) + 1)
|
|
end
|
|
IDM:insert(x, y, dist)
|
|
end)
|
|
else
|
|
local width, height = wesnoth.get_map_size()
|
|
for x = 1,width do
|
|
for y = 1,height do
|
|
local dist = 0
|
|
for _,unit in ipairs(units) do
|
|
dist = dist + 1. / (M.distance_between(unit.x, unit.y, x, y) + 1)
|
|
end
|
|
IDM:insert(x, y, dist)
|
|
end
|
|
end
|
|
end
|
|
|
|
return IDM
|
|
end
|
|
|
|
function ai_helper.generalized_distance(x1, y1, x2, y2)
|
|
-- Determines distance of (@x1,@y1) from (@x2,@y2) even if
|
|
-- @x2 and @y2 are not necessarily both given (or not numbers)
|
|
|
|
-- 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
|
|
|
|
function ai_helper.xyoff(x, y, ori, hex)
|
|
-- Finds hexes at a certain offset from @x,@y
|
|
-- @ori: direction/orientation: north (0), ne (1), se (2), s (3), sw (4), nw (5)
|
|
-- @hex: string for the hex to be queried. Possible values:
|
|
-- 's': self, 'u': up, 'lu': left up, 'ld': left down, 'ru': right up, 'rd': right down
|
|
-- This is all relative "looking" in the direction of 'ori'
|
|
-- returns x,y for the queried hex
|
|
|
|
-- Unlike Lua default, we count 'ori' from 0 (north) to 5 (nw), so that modulo operator can be used
|
|
ori = ori % 6
|
|
|
|
if (hex == 's') then return x, y end
|
|
|
|
-- This is all done with ifs, to keep it as fast as possible
|
|
if (ori == 0) then -- "north"
|
|
if (hex == 'u') then return x, y-1 end
|
|
if (hex == 'd') then return x, y+1 end
|
|
local dy = 0
|
|
if (x % 2) == 1 then dy=1 end
|
|
if (hex == 'lu') then return x-1, y-dy end
|
|
if (hex == 'ld') then return x-1, y+1-dy end
|
|
if (hex == 'ru') then return x+1, y-dy end
|
|
if (hex == 'rd') then return x+1, y+1-dy end
|
|
end
|
|
|
|
if (ori == 1) then -- "north-east"
|
|
local dy = 0
|
|
if (x % 2) == 1 then dy=1 end
|
|
if (hex == 'u') then return x+1, y-dy end
|
|
if (hex == 'd') then return x-1, y+1-dy end
|
|
if (hex == 'lu') then return x, y-1 end
|
|
if (hex == 'ld') then return x-1, y-dy end
|
|
if (hex == 'ru') then return x+1, y+1-dy end
|
|
if (hex == 'rd') then return x, y+1 end
|
|
end
|
|
|
|
if (ori == 2) then -- "south-east"
|
|
local dy = 0
|
|
if (x % 2) == 1 then dy=1 end
|
|
if (hex == 'u') then return x+1, y+1-dy end
|
|
if (hex == 'd') then return x-1, y-dy end
|
|
if (hex == 'lu') then return x+1, y-dy end
|
|
if (hex == 'ld') then return x, y-1 end
|
|
if (hex == 'ru') then return x, y+1 end
|
|
if (hex == 'rd') then return x-1, y+1-dy end
|
|
end
|
|
|
|
if (ori == 3) then -- "south"
|
|
if (hex == 'u') then return x, y+1 end
|
|
if (hex == 'd') then return x, y-1 end
|
|
local dy = 0
|
|
if (x % 2) == 1 then dy=1 end
|
|
if (hex == 'lu') then return x+1, y+1-dy end
|
|
if (hex == 'ld') then return x+1, y-dy end
|
|
if (hex == 'ru') then return x-1, y+1-dy end
|
|
if (hex == 'rd') then return x-1, y-dy end
|
|
end
|
|
|
|
if (ori == 4) then -- "south-west"
|
|
local dy = 0
|
|
if (x % 2) == 1 then dy=1 end
|
|
if (hex == 'u') then return x-1, y+1-dy end
|
|
if (hex == 'd') then return x+1, y-dy end
|
|
if (hex == 'lu') then return x, y+1 end
|
|
if (hex == 'ld') then return x+1, y+1-dy end
|
|
if (hex == 'ru') then return x-1, y-dy end
|
|
if (hex == 'rd') then return x, y-1 end
|
|
end
|
|
|
|
if (ori == 5) then -- "north-west"
|
|
local dy = 0
|
|
if (x % 2) == 1 then dy=1 end
|
|
if (hex == 'u') then return x-1, y-dy end
|
|
if (hex == 'd') then return x+1, y+1-dy end
|
|
if (hex == 'lu') then return x-1, y+1-dy end
|
|
if (hex == 'ld') then return x, y+1 end
|
|
if (hex == 'ru') then return x, y-1 end
|
|
if (hex == 'rd') then return x+1, y-dy end
|
|
end
|
|
|
|
return
|
|
end
|
|
|
|
function ai_helper.split_location_list_to_strings(list)
|
|
-- Convert a list of locations @list as returned by wesnoth.get_locations into a pair of strings
|
|
-- suitable for passing in as x,y coordinate lists to wesnoth.get_locations.
|
|
-- Could alternatively convert to a WML table and use the find_in argument, but this is simpler.
|
|
local locsx, locsy = {}, {}
|
|
for i,loc in ipairs(list) do
|
|
locsx[i] = loc[1]
|
|
locsy[i] = loc[2]
|
|
end
|
|
locsx = table.concat(locsx, ",")
|
|
locsy = table.concat(locsy, ",")
|
|
|
|
return locsx, locsy
|
|
end
|
|
|
|
--------- Unit related helper functions ----------
|
|
|
|
function ai_helper.get_live_units(filter)
|
|
-- Note: the order of the filters and the [and] tags are important for speed reasons
|
|
return wesnoth.get_units { { "not", { status = "petrified" } }, { "and", filter } }
|
|
end
|
|
|
|
function ai_helper.get_units_with_moves(filter)
|
|
-- Note: the order of the filters and the [and] tags are important for speed reasons
|
|
return wesnoth.get_units {
|
|
{ "and", { formula = "moves > 0" } },
|
|
{ "not", { status = "petrified" } },
|
|
{ "and", filter }
|
|
}
|
|
end
|
|
|
|
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.get_units {
|
|
{ "and", { formula = "attacks_left > 0 and size(attacks) > 0" } },
|
|
{ "not", { status = "petrified" } },
|
|
{ "and", filter }
|
|
}
|
|
end
|
|
|
|
function ai_helper.get_visible_units(viewing_side, filter)
|
|
-- Get units that are visible to side @viewing_side
|
|
--
|
|
-- Required parameters:
|
|
-- @viewing_side: see comments at beginning of this file
|
|
--
|
|
-- Optional parameters:
|
|
-- @filter: Standard unit filter WML table for the units
|
|
-- Example 1: { type = 'Orcish Grunt' }
|
|
-- Example 2: { { "filter_location", { x = 10, y = 12, radius = 5 } } }
|
|
|
|
if (not viewing_side) then
|
|
error('ai_helper.get_visible_units() is missing required parameter viewing_side.', 2)
|
|
end
|
|
if (type(viewing_side) ~= 'number') then
|
|
error('ai_helper.get_visible_units(): parameter viewing_side must be a number., 2')
|
|
end
|
|
|
|
local filter_plus_vision = {}
|
|
if filter then filter_plus_vision = ai_helper.table_copy(filter) end
|
|
if wesnoth.sides[viewing_side] then
|
|
table.insert(filter_plus_vision, { "filter_vision", { side = viewing_side, visible = 'yes' } })
|
|
end
|
|
|
|
local units = {}
|
|
local all_units = wesnoth.get_units()
|
|
for _,unit in ipairs(all_units) do
|
|
if unit:matches(filter_plus_vision) then
|
|
table.insert(units, unit)
|
|
end
|
|
end
|
|
|
|
return units
|
|
end
|
|
|
|
function ai_helper.is_visible_unit(viewing_side, unit)
|
|
-- Check whether @unit exists and is visible to side @viewing_side.
|
|
--
|
|
-- Required parameters:
|
|
-- @viewing_side: see comments at beginning of this file.
|
|
-- @unit: unit proxy table
|
|
|
|
if (not viewing_side) then
|
|
error('ai_helper.is_visible_unit() is missing required parameter viewing_side.', 2)
|
|
end
|
|
if (type(viewing_side) ~= 'number') then
|
|
error('ai_helper.is_visible_unit(): parameter viewing_side must be a number.', 2)
|
|
end
|
|
|
|
if (not unit) then return false end
|
|
|
|
if wesnoth.sides[viewing_side]
|
|
and unit:matches({ { "filter_vision", { side = viewing_side, visible = 'no' } } })
|
|
then
|
|
return false
|
|
end
|
|
|
|
return true
|
|
end
|
|
|
|
function ai_helper.get_attackable_enemies(filter, side, cfg)
|
|
-- Attackable enemies are defined as being being
|
|
-- - enemies of the side defined in @side,
|
|
-- - not petrified
|
|
-- - and visible to the side defined in @cfg.viewing_side.
|
|
-- For speed reasons, this is done separately, rather than calling ai_helper.get_visible_units().
|
|
--
|
|
-- Optional parameters:
|
|
-- @filter: Standard unit filter WML table for the enemies
|
|
-- Example 1: { type = 'Orcish Grunt' }
|
|
-- Example 2: { { "filter_location", { x = 10, y = 12, radius = 5 } } }
|
|
-- @side: side number, if side other than current side is to be considered
|
|
-- @cfg: table with optional configuration parameters:
|
|
-- viewing_side: see comments at beginning of this file. Defaults to @side.
|
|
|
|
side = side or wesnoth.current.side
|
|
local viewing_side = cfg and cfg.viewing_side or side
|
|
|
|
local filter_plus_vision = {}
|
|
if filter then filter_plus_vision = ai_helper.table_copy(filter) end
|
|
if wesnoth.sides[viewing_side] then
|
|
table.insert(filter_plus_vision, { "filter_vision", { side = viewing_side, visible = 'yes' } })
|
|
end
|
|
|
|
local enemies = {}
|
|
local all_units = wesnoth.get_units()
|
|
for _,unit in ipairs(all_units) do
|
|
if wesnoth.is_enemy(side, unit.side)
|
|
and (not unit.status.petrified)
|
|
and unit:matches(filter_plus_vision)
|
|
then
|
|
table.insert(enemies, unit)
|
|
end
|
|
end
|
|
|
|
return enemies
|
|
end
|
|
|
|
function ai_helper.is_attackable_enemy(unit, side, cfg)
|
|
-- Check if @unit exists, is an enemy of @side, is visible to the side defined
|
|
-- in @cfg.viewing_side and is not petrified.
|
|
--
|
|
-- Optional parameters:
|
|
-- @side: side number, defaults to current side.
|
|
-- @cfg: table with optional configuration parameters:
|
|
-- viewing_side: see comments at beginning of this file. Defaults to @side.
|
|
|
|
side = side or wesnoth.current.side
|
|
local viewing_side = cfg and cfg.viewing_side or side
|
|
|
|
if (not unit)
|
|
or (not wesnoth.is_enemy(side, unit.side))
|
|
or unit.status.petrified
|
|
or (not ai_helper.is_visible_unit(viewing_side, unit))
|
|
then
|
|
return false
|
|
end
|
|
|
|
return true
|
|
end
|
|
|
|
function ai_helper.get_closest_enemy(loc, side, cfg)
|
|
-- Return distance to and location of the enemy closest to @loc, or to the
|
|
-- leader of side with number @side if @loc is not specified
|
|
--
|
|
-- Optional parameters:
|
|
-- @loc: location in format { x , y }
|
|
-- @side: number of side for which to find enemy; defaults to current side
|
|
-- @cfg: table with optional configuration parameters:
|
|
-- viewing_side: see comments at beginning of this file. Defaults to @side.
|
|
|
|
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.get_units { side = side, canrecruit = 'yes' }[1]
|
|
x, y = leader.x, leader.y
|
|
else
|
|
x, y = loc[1], loc[2]
|
|
end
|
|
|
|
local closest_distance, location = math.huge
|
|
for _,enemy in ipairs(enemies) do
|
|
enemy_distance = M.distance_between(x, y, enemy.x, enemy.y)
|
|
if (enemy_distance < closest_distance) then
|
|
closest_distance = enemy_distance
|
|
location = { x = enemy.x, y = enemy.y}
|
|
end
|
|
end
|
|
|
|
return closest_distance, location
|
|
end
|
|
|
|
function ai_helper.has_ability(unit, ability)
|
|
-- Returns true/false depending on whether unit has the given ability
|
|
local has_ability = false
|
|
local abilities = wml.get_child(unit.__cfg, "abilities")
|
|
if abilities then
|
|
if wml.get_child(abilities, ability) then has_ability = true end
|
|
end
|
|
return has_ability
|
|
end
|
|
|
|
function ai_helper.has_weapon_special(unit, special)
|
|
-- Returns true/false depending on whether @unit has a weapon with special @special
|
|
-- Also returns the number of the first weapon with this special
|
|
for weapon_number,att in ipairs(unit.attacks) do
|
|
for _,sp in ipairs(att.specials) do
|
|
if (sp[1] == special) then
|
|
return true, weapon_number
|
|
end
|
|
end
|
|
end
|
|
return false
|
|
end
|
|
|
|
function ai_helper.get_cheapest_recruit_cost()
|
|
local cheapest_unit_cost = math.huge
|
|
for _,recruit_id in ipairs(wesnoth.sides[wesnoth.current.side].recruit) 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
|
|
|
|
function ai_helper.get_dst_src_units(units, cfg)
|
|
-- Get the dst_src location set for @units
|
|
-- @cfg: table with optional configuration parameters:
|
|
-- moves: if set to 'max' use max_moves of units, rather than current moves
|
|
-- all parameters for wesnoth.find_reach
|
|
|
|
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 = unit.moves
|
|
if max_moves then
|
|
unit.moves = unit.max_moves
|
|
end
|
|
local reach = wesnoth.find_reach(unit, cfg)
|
|
if max_moves then
|
|
unit.moves = tmp
|
|
end
|
|
|
|
for _,loc in ipairs(reach) do
|
|
local tmp = dstsrc:get(loc[1], loc[2]) or {}
|
|
table.insert(tmp, { x = unit.x, y = unit.y })
|
|
dstsrc:insert(loc[1], loc[2], tmp)
|
|
end
|
|
end
|
|
|
|
return dstsrc
|
|
end
|
|
|
|
function ai_helper.get_dst_src(units, cfg)
|
|
-- If @units table is given use it, otherwise use all units on the current side
|
|
-- @cfg: table with optional configuration parameters:
|
|
-- moves: if set to 'max' use max_moves of units, rather than current moves
|
|
-- all parameters for wesnoth.find_reach
|
|
|
|
if (not units) then
|
|
units = wesnoth.get_units { side = wesnoth.current.side }
|
|
end
|
|
|
|
return ai_helper.get_dst_src_units(units, cfg)
|
|
end
|
|
|
|
function ai_helper.get_enemy_dst_src(enemies, cfg)
|
|
-- If @enemies table is given use it, otherwise use all enemy units
|
|
-- @cfg: table with optional configuration parameters:
|
|
-- all parameters for wesnoth.find_reach
|
|
|
|
if (not enemies) then
|
|
enemies = wesnoth.get_units {
|
|
{ "filter_side", { { "enemy_of", { side = wesnoth.current.side} } } }
|
|
}
|
|
end
|
|
|
|
local cfg_copy = {}
|
|
if cfg then cfg_copy = ai_helper.table_copy(attack_array) end
|
|
cfg_copy.moves = 'max'
|
|
|
|
return ai_helper.get_dst_src_units(enemies, cfg_copy)
|
|
end
|
|
|
|
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_dstsrc()
|
|
|
|
local my_moves = {}
|
|
for key,value in pairs(dstsrc) do
|
|
table.insert( my_moves,
|
|
{ src = { x = value[1].x , y = value[1].y },
|
|
dst = { x = key.x , y = key.y }
|
|
}
|
|
)
|
|
end
|
|
|
|
return my_moves
|
|
end
|
|
|
|
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_dstsrc()
|
|
|
|
local enemy_moves = {}
|
|
for key,value in pairs(dstsrc) do
|
|
table.insert( enemy_moves,
|
|
{ src = { x = value[1].x , y = value[1].y },
|
|
dst = { x = key.x , y = key.y }
|
|
}
|
|
)
|
|
end
|
|
|
|
return enemy_moves
|
|
end
|
|
|
|
function ai_helper.next_hop(unit, x, y, cfg)
|
|
-- 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
|
|
-- @cfg: standard extra options for wesnoth.find_path()
|
|
-- including:
|
|
-- viewing_side: see comments at beginning of this file. Defaults to side of @unit
|
|
-- plus:
|
|
-- ignore_own_units: if set to true, then own units that can move out of the way are ignored
|
|
|
|
local path, cost = ai_helper.find_path_with_shroud(unit, x, y, cfg)
|
|
|
|
if cost >= ai_helper.no_path then return nil, cost end
|
|
|
|
-- If none of the hexes are unoccupied, use current position as default
|
|
local next_hop, nh_cost = { unit.x, unit.y }, 0
|
|
|
|
-- 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
|
|
local sub_path, sub_cost = ai_helper.find_path_with_shroud(unit, path[i][1], path[i][2], 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.get_unit(path[i][1], path[i][2])
|
|
|
|
-- 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
|
|
else
|
|
break
|
|
end
|
|
end
|
|
|
|
return next_hop, nh_cost
|
|
end
|
|
|
|
function ai_helper.can_reach(unit, x, y, cfg)
|
|
-- Returns true if hex (@x, @y) is unoccupied (by a visible unit), or
|
|
-- at most occupied by unit on same side as @unit that can move away
|
|
-- (can be modified with options below)
|
|
--
|
|
-- @cfg: table with optional configuration parameters:
|
|
-- moves = 'max' use max_moves instead of current moves
|
|
-- ignore_units: if true, ignore both own and enemy units
|
|
-- exclude_occupied: if true, exclude hex if there's a unit there, irrespective of value of 'ignore_units'
|
|
-- viewing_side: see comments at beginning of this file. Defaults to side of @unit
|
|
|
|
cfg = cfg or {}
|
|
local viewing_side = cfg.viewing_side or unit.side
|
|
|
|
-- Is there a unit at the goal hex?
|
|
local unit_in_way = wesnoth.get_unit(x, y)
|
|
if (cfg.exclude_occupied)
|
|
and unit_in_way and ai_helper.is_visible_unit(viewing_side, 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)
|
|
and ai_helper.is_visible_unit(viewing_side, unit_in_way)
|
|
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
|
|
|
|
function ai_helper.get_reachable_unocc(unit, cfg)
|
|
-- Get all reachable hexes for @unit that are (or appear) unoccupied, taking
|
|
-- vision into account as specified by the @cfg.viewing_side parameter.
|
|
-- Returned array is a location set, with value = 1 for each reachable hex
|
|
--
|
|
-- @cfg: table with optional configuration parameters:
|
|
-- moves: if set to 'max', unit MP is set to max_moves before calculation
|
|
-- viewing_side: see comments at beginning of this file. Defaults to side of @unit
|
|
-- plus all other parameters to wesnoth.find_reach
|
|
|
|
local viewing_side = cfg and cfg.viewing_side or unit.side
|
|
|
|
local old_moves = unit.moves
|
|
if cfg then
|
|
if (cfg.moves == 'max') then unit.moves = unit.max_moves end
|
|
end
|
|
|
|
local reach = LS.create()
|
|
local initial_reach = wesnoth.find_reach(unit, cfg)
|
|
for _,loc in ipairs(initial_reach) do
|
|
local unit_in_way = wesnoth.get_unit(loc[1], loc[2])
|
|
if (not unit_in_way) or (not ai_helper.is_visible_unit(viewing_side, unit_in_way)) then
|
|
reach:insert(loc[1], loc[2], 1)
|
|
end
|
|
end
|
|
|
|
-- Also need to include the hex the unit is on itself
|
|
reach:insert(unit.x, unit.y, 1)
|
|
|
|
unit.moves = old_moves
|
|
|
|
return reach
|
|
end
|
|
|
|
function ai_helper.find_path_with_shroud(unit, x, y, cfg)
|
|
-- Same as wesnoth.find_path, just that it works under shroud as well while still
|
|
-- ignoring invisible units. It does this by using viewing_side=0 and taking
|
|
-- invisible units off the map for the path finding process.
|
|
--
|
|
-- 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_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
|
|
|
|
local path, cost
|
|
if wesnoth.sides[viewing_side] and wesnoth.sides[viewing_side].shroud then
|
|
local extracted_units = {}
|
|
if (not cfg) or (not cfg.ignore_units) then
|
|
local all_units = wesnoth.get_units()
|
|
for _,u in ipairs(all_units) do
|
|
if (u.side ~= viewing_side)
|
|
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.viewing_side = 0
|
|
path, cost = wesnoth.find_path(unit, x, y, cfg_copy)
|
|
|
|
for _,extracted_unit in ipairs(extracted_units) do
|
|
extracted_unit:to_map()
|
|
end
|
|
else
|
|
path, cost = wesnoth.find_path(unit, x, y, cfg)
|
|
end
|
|
|
|
return path, cost
|
|
end
|
|
|
|
function ai_helper.find_best_move(units, rating_function, cfg)
|
|
-- Find the best move and best unit based on @rating_function
|
|
-- INPUTS:
|
|
-- @units: (required) single unit or table of units
|
|
-- @rating_function: (required) function(x, y) with rating function for the hexes the unit can reach
|
|
-- @cfg: table with optional configuration parameters:
|
|
-- labels: if set, put labels with ratings onto map
|
|
-- no_random: if set, do not add random value between 0.0001 and 0.0099 to each hex
|
|
-- plus all the possible parameters for ai_helper.get_reachable_unocc()
|
|
--
|
|
-- OUTPUTS:
|
|
-- best_hex: format { x, y }
|
|
-- best_unit: unit for which this rating function produced the maximum value
|
|
-- max_rating: the rating found for this hex/unit combination
|
|
-- If no valid moves were found, best_unit and best_hex are nil
|
|
|
|
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
|
|
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
|
|
|
|
function ai_helper.move_unit_out_of_way(ai, unit, cfg)
|
|
-- Move @unit to the best close location.
|
|
-- Main rating is the moves the unit still has left after that
|
|
-- Other, configurable, parameters are given to function in @cfg:
|
|
-- dx, dy: the direction in which moving out of the way is preferred
|
|
-- labels: if set, display labels of the rating for each hex the unit can reach
|
|
-- viewing_side: see comments at beginning of this file. Defaults to side of @unit.
|
|
-- all other optional parameters to wesnoth.find_reach()
|
|
|
|
cfg = cfg or {}
|
|
local viewing_side = cfg.viewing_side or unit.side
|
|
|
|
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.find_reach(unit, cfg)
|
|
local reach_map = LS.create()
|
|
|
|
local max_rating, best_hex = - math.huge
|
|
for _,loc in ipairs(reach) do
|
|
local unit_in_way = wesnoth.get_unit(loc[1], loc[2])
|
|
if (not unit_in_way) -- also excludes current hex
|
|
or (not ai_helper.is_visible_unit(viewing_side, unit_in_way))
|
|
then
|
|
local rating = loc[3] -- also disfavors hexes next to visible enemy units for which loc[3] = 0
|
|
|
|
if dx then
|
|
rating = rating + (loc[1] - unit.x) * dx * 0.01
|
|
rating = rating + (loc[2] - unit.y) * dy * 0.01
|
|
end
|
|
|
|
if cfg.labels then reach_map:insert(loc[1], loc[2], rating) end
|
|
|
|
if (rating > max_rating) then
|
|
max_rating, best_hex = rating, { loc[1], loc[2] }
|
|
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[1], best_hex[2])
|
|
end
|
|
end
|
|
|
|
function ai_helper.movefull_stopunit(ai, unit, x, y)
|
|
-- Does ai.move_full for @unit if not at (@x,@y), otherwise ai.stopunit_moves
|
|
-- Uses ai_helper.next_hop(), so that it works if unit cannot get there in one move
|
|
-- Coordinates can be given as x and y components, or as a 2-element table { x, y } or { x = x, y = 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
|
|
|
|
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
|
|
|
|
function ai_helper.movefull_outofway_stopunit(ai, unit, x, y, cfg)
|
|
-- Same as ai_help.movefull_stopunit(), but also moves a unit out of the way if there is one
|
|
--
|
|
-- @cfg: table with optional configuration parameters:
|
|
-- viewing_side: see comments at beginning of this file. Defaults to side of @unit
|
|
-- all other optional parameters to ai_helper.move_unit_out_of_way() and wesnoth.find_path()
|
|
|
|
local viewing_side = cfg and cfg.viewing_side or unit.side
|
|
|
|
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.get_unit(x, y)
|
|
if unit_in_way and (unit_in_way ~= unit)
|
|
and 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 --------------
|
|
|
|
function ai_helper.get_attacks(units, cfg)
|
|
-- Get all attacks the units stored in @units can do. Invisible enemies are
|
|
-- excluded unless option @cfg.viewing_side=0 is used.
|
|
--
|
|
-- This includes a variety of configurable options, passed in the @cfg table
|
|
-- @cfg: table with optional configuration parameters:
|
|
-- moves: "current" (default for units on current side) or "max" (always used for units on other sides)
|
|
-- include_occupied (false): if set, also include hexes occupied by own-side units that can move away
|
|
-- simulate_combat (false): if set, also simulate the combat and return result (this is slow; only set if needed)
|
|
-- viewing_side: see comments at beginning of this file. Defaults to side of @units
|
|
-- all other optional parameters to wesnoth.find_reach()
|
|
--
|
|
-- Returns {} if no attacks can be done, otherwise table with fields:
|
|
-- dst: { x = x, y = y } of attack position
|
|
-- src: { x = x, y = y } of attacking unit (don't use id, could be ambiguous)
|
|
-- target: { x = x, y = y } of defending unit
|
|
-- att_stats, def_stats: as returned by wesnoth.simulate_combat (if cfg.simulate_combat is set)
|
|
-- attack_hex_occupied: boolean storing whether an own unit that can move away is on the attack hex
|
|
|
|
cfg = cfg or {}
|
|
|
|
local attacks = {}
|
|
if (not units[1]) then return attacks end
|
|
|
|
local side = units[1].side -- all units need to be on same side
|
|
local viewing_side = cfg and cfg.viewing_side or side
|
|
|
|
-- '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.get_units()
|
|
|
|
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 ai_helper.is_visible_unit(viewing_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 H.adjacent_tiles(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
|
|
local reach
|
|
if reaches:get(unit.x, unit.y) then
|
|
reach = reaches:get(unit.x, unit.y)
|
|
else
|
|
reach = wesnoth.find_reach(unit, cfg)
|
|
reaches:insert(unit.x, unit.y, reach)
|
|
end
|
|
|
|
for _,loc in ipairs(reach) do
|
|
if attack_hex_map:get(loc[1], loc[2]) 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[1], loc[2]) and ((loc[1] ~= unit.x) or (loc[2] ~= 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[1], loc[2])]
|
|
local uiw_reach
|
|
if reaches:get(unit_in_way.x, unit_in_way.y) then
|
|
uiw_reach = reaches:get(unit_in_way.x, unit_in_way.y)
|
|
else
|
|
uiw_reach = wesnoth.find_reach(unit_in_way, 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.get_unit(uiw_loc[1], uiw_loc[2])
|
|
if (not uiw_uiw) or (not ai_helper.is_visible_unit(viewing_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[1], loc[2])) do
|
|
local att_stats, def_stats
|
|
if cfg.simulate_combat then
|
|
local unit_dst = unit:clone()
|
|
unit_dst.x, unit_dst.y = loc[1], loc[2]
|
|
|
|
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[1], y = loc[2] },
|
|
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
|
|
|
|
function ai_helper.add_next_attack_combo_level(combos, attacks)
|
|
-- 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
|
|
-- 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 = ai_helper.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
|
|
|
|
function ai_helper.get_attack_combos_full(units, enemy, cfg)
|
|
-- 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
|
|
--
|
|
-- Optional inputs:
|
|
-- @cfg: Configuration table to be passed on to ai_helper.get_attacks()
|
|
--
|
|
-- Return value:
|
|
-- 1. Attack combinations in form { dst = src }
|
|
|
|
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 = ai_helper.add_next_attack_combo_level(combos, attacks)
|
|
|
|
return combos
|
|
end
|
|
|
|
function ai_helper.get_attack_combos(units, enemy, cfg)
|
|
-- 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.
|
|
--
|
|
-- Optional inputs:
|
|
-- @cfg: Configuration table to be passed on to wesnoth.find_path()
|
|
--
|
|
-- 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.get_units { 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 H.adjacent_tiles(enemy.x, enemy.y) 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.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
|
|
end
|
|
|
|
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
|
|
multipler = (1 - math.abs(lawful_bonus) / 100.)
|
|
end
|
|
end
|
|
return multiplier
|
|
end
|
|
|
|
return ai_helper
|