wesnoth/data/ai/lua/ca_castle_switch.lua
mattsc e4b776cbb8 ExpAI: do not use generic_recruit_engine
This removes the need for the Generic Recruit Engine and makes Recruit Rushers a "clean" candidate action.  In the process, it fixes a bug of data leakage between the Experimental AIs of different sides.
2022-06-22 16:31:28 -07:00

262 lines
11 KiB
Lua

-------- Castle Switch CA --------------
local AH = wesnoth.require "ai/lua/ai_helper.lua"
local M = wesnoth.map
local CS_leader_score
-- Note that CS_leader and CS_leader_target are also needed by the recruiting CA, so they must be stored in 'data'
local high_score = 195000
local low_score = 15000
local function get_reachable_enemy_leaders(unit, avoid_map)
-- We're cheating a little here and also find hidden enemy leaders. That's
-- because a human player could make a pretty good educated guess as to where
-- the enemy leaders are likely to be while the AI does not know how to do that.
local potential_enemy_leaders = AH.get_live_units { canrecruit = 'yes',
{ "filter_side", { { "enemy_of", {side = wesnoth.current.side} } } }
}
local enemy_leaders = {}
for _,e in ipairs(potential_enemy_leaders) do
-- Cannot use AH.find_path_with_avoid() here as there might be enemies all around the enemy leader
if (not avoid_map:get(e.x, e.y)) then
local path, cost = wesnoth.paths.find_path(unit, e.x, e.y, { ignore_units = true, ignore_visibility = true })
if cost < AH.no_path then
table.insert(enemy_leaders, e)
end
end
end
return enemy_leaders
end
local function other_units_on_keep(leader)
-- if we're on a keep, wait until there are no movable non-leader units on the castle before moving off
local leader_score = high_score
if wesnoth.terrain_types[wesnoth.current.map[leader]].keep then
local castle = AH.get_locations_no_borders {
{ "and", {
x = leader.x, y = leader.y, radius = 200,
{ "filter_radius", { terrain = 'C*,K*,C*^*,K*^*,*^K*,*^C*' } }
}}
}
local should_wait = false
for i,loc in ipairs(castle) do
local unit = wesnoth.units.get(loc[1], loc[2])
if unit and (unit.side == wesnoth.current.side) and (not unit.canrecruit) and (unit.moves > 0) then
should_wait = true
break
end
end
if should_wait then
leader_score = low_score
end
end
return leader_score
end
local ca_castle_switch = {}
function ca_castle_switch:evaluation(cfg, data, filter_own, recruiting_leader)
-- @recruiting_leader is passed from the recuit_rushers CA for the leader_takes_villages evaluation
-- evaluation. If it is set, we do the castle switch evaluation only for that leader
local start_time, ca_name = wesnoth.ms_since_init() / 1000., 'castle_switch'
if AH.print_eval() then AH.print_ts(' - Evaluating castle_switch CA:') end
if ai.aspects.passive_leader then
-- Turn off this CA if the leader is passive
return 0
end
local leaders
if recruiting_leader then
-- Note that doing this might set the stored castle switch information to a different leader.
-- This is fine though, the order in which these are done is not particularly important.
leaders = { recruiting_leader }
else
leaders = AH.get_units_with_moves({
side = wesnoth.current.side,
canrecruit = 'yes',
formula = '(movement_left = total_movement) and (hitpoints = max_hitpoints)',
{ "and", filter_own }
}, true)
end
local non_passive_leader
for _,l in pairs(leaders) do
if (not AH.is_passive_leader(ai.aspects.passive_leader, l.id)) then
non_passive_leader = l
break
end
end
if not non_passive_leader then
-- CA is irrelevant if no leader or the leader may have moved from another CA
data.CS_leader, data.CS_leader_target = nil, nil
if AH.print_eval() then AH.done_eval_messages(start_time, ca_name) end
return 0
end
-- Also need to check that the stored leader has not been killed
if data.CS_leader and not data.CS_leader.valid then
data.CS_leader, data.CS_leader_target = nil, nil
end
local avoid_map = AH.get_avoid_map(ai, nil, true)
if data.CS_leader and wesnoth.sides[wesnoth.current.side].gold >= AH.get_cheapest_recruit_cost(data.CS_leader)
and ((not recruiting_leader) or (recruiting_leader.id == data.CS_leader.id))
then
-- If the saved score is the low score, check whether there are still other units on the keep
if (CS_leader_score == low_score) then
CS_leader_score = other_units_on_keep(data.CS_leader)
end
-- make sure move is still valid
local path, cost = AH.find_path_with_avoid(data.CS_leader, data.CS_leader_target[1], data.CS_leader_target[2], avoid_map)
local next_hop = AH.next_hop(data.CS_leader, nil, nil, { path = path, avoid_map = avoid_map })
if next_hop and next_hop[1] == data.CS_leader_target[1]
and next_hop[2] == data.CS_leader_target[2]
then
return CS_leader_score
else
data.CS_leader, data.CS_leader_target = nil, nil
end
end
-- Look for the best keep
local overall_best_score = 0
for _,leader in ipairs(leaders) do
local best_score, best_loc, best_turns, best_path = 0, {}, 3, nil
local keeps = AH.get_locations_no_borders {
terrain = 'K*,K*^*,*^K*', -- Keeps
{ "not", { {"filter", {}} }}, -- That have no unit
{ "not", { radius = 6, {"filter", { canrecruit = 'yes',
{ "filter_side", { { "enemy_of", {side = wesnoth.current.side} } } }
}} }}, -- That are not too close to an enemy leader
{ "not", {
x = leader.x, y = leader.y, terrain = 'K*,K*^*,*^K*',
radius = 3,
{ "filter_radius", { terrain = 'C*,K*,C*^*,K*^*,*^K*,*^C*' } }
}}, -- That are not close and connected to a keep the leader is on
{ "filter_adjacent_location", {
terrain = 'C*,K*,C*^*,K*^*,*^K*,*^C*'
}} -- That are not one-hex keeps
}
if #keeps < 1 then
-- Skip if there aren't extra keeps to evaluate
-- In this situation we'd only switch keeps if we were running away
if AH.print_eval() then AH.done_eval_messages(start_time, ca_name) end
return 0
end
local enemy_leaders = get_reachable_enemy_leaders(leader, avoid_map)
for i,loc in ipairs(keeps) do
-- Only consider keeps within 2 turns movement
local path, cost = AH.find_path_with_avoid(leader, loc[1], loc[2], avoid_map)
local score = 0
-- Prefer closer keeps to enemy
local turns = math.ceil(cost/leader.max_moves)
if turns <= 2 then
score = 1/turns
for j,e in ipairs(enemy_leaders) do
score = score + 1 / M.distance_between(loc[1], loc[2], e.x, e.y)
end
if score > best_score then
best_score = score
best_loc = loc
best_turns = turns
best_path = path
end
end
end
-- If we're on a keep,
-- don't move to another keep unless it's much better when uncaptured villages are present
if best_score > 0 and wesnoth.terrain_types[wesnoth.current.map[leader]].keep then
local close_unowned_village = (wesnoth.map.find {
wml.tag['and']{
x = leader.x,
y = leader.y,
radius = leader.max_moves
},
gives_income = true,
owner_side = 0
})[1]
if close_unowned_village then
local score = 1/best_turns
for j,e in ipairs(enemy_leaders) do
-- count all distances as three less than they actually are
score = score + 1 / (M.distance_between(leader.x, leader.y, e.x, e.y) - 3)
end
if score > best_score then
best_score = 0
end
end
end
if best_score > 0 then
local next_hop = AH.next_hop(leader, nil, nil, { path = best_path, avoid_map = avoid_map })
if next_hop and ((next_hop[1] ~= leader.x) or (next_hop[2] ~= leader.y)) then
-- See if there is a nearby village that can be captured without delaying progress
local close_villages = wesnoth.map.find( {
wml.tag["and"]{ x = next_hop[1], y = next_hop[2], radius = leader.max_moves },
gives_income = true,
owner_side = 0 })
local cheapest_unit_cost = AH.get_cheapest_recruit_cost(leader)
for i,loc in ipairs(close_villages) do
local path_village, cost_village = AH.find_path_with_avoid(leader, loc[1], loc[2], avoid_map)
if cost_village <= leader.moves then
local dummy_leader = leader:clone()
dummy_leader.x = loc[1]
dummy_leader.y = loc[2]
local path_keep, cost_keep = wesnoth.paths.find_path(dummy_leader, best_loc[1], best_loc[2], avoid_map)
local turns_from_keep = math.ceil(cost_keep/leader.max_moves)
if turns_from_keep < best_turns
or (turns_from_keep == 1 and wesnoth.sides[wesnoth.current.side].gold < cheapest_unit_cost)
then
-- There is, go there instead
next_hop = loc
break
end
end
end
end
local leader_score = other_units_on_keep(leader)
best_score = best_score + leader_score
if (best_score > overall_best_score) then
overall_best_score = best_score
CS_leader_score = leader_score
data.CS_leader = leader
data.CS_leader_target = next_hop
end
end
end
if (overall_best_score > 0) then
if AH.print_eval() then AH.done_eval_messages(start_time, ca_name) end
return CS_leader_score
end
if AH.print_eval() then AH.done_eval_messages(start_time, ca_name) end
return 0
end
function ca_castle_switch:execution(cfg, data, filter_own)
if AH.print_exec() then AH.print_ts(' Executing castle_switch CA') end
if AH.show_messages() then wesnoth.wml_actions.message { speaker = data.leader.id, message = 'Switching castles' } end
AH.robust_move_and_attack(ai, data.CS_leader, data.CS_leader_target, nil, { partial_move = true })
data.CS_leader, data.CS_leader_target = nil, nil
end
return ca_castle_switch