From 5d7c5f6fe6b59c77b7d389cceb9b9fcf6600f7fa Mon Sep 17 00:00:00 2001 From: Simon Forsyth Date: Sat, 22 Dec 2012 03:03:48 +0000 Subject: [PATCH] Improvements to experimental multiplayer AI recruiting --- changelog | 5 + data/ai/lua/ai_helper.lua | 14 ++ data/ai/lua/generic-recruit_engine.lua | 284 ++++++++++++++++--------- data/ai/lua/generic-rush_engine.lua | 112 ++++++---- players_changelog | 6 + 5 files changed, 280 insertions(+), 141 deletions(-) diff --git a/changelog b/changelog index 12b991c845c..079333119cb 100644 --- a/changelog +++ b/changelog @@ -1,4 +1,9 @@ Version 1.11.1+svn: + * AI: + * Experimental Multiplayer AI + * Improve recruitment, notably first turn choices and units with poison + * Improved selection of units for village stealing + * Remove dependency on AI-demos add-on * Graphics: * Fix layering error with bridges * Language and i18n: diff --git a/data/ai/lua/ai_helper.lua b/data/ai/lua/ai_helper.lua index eb5bb7a521f..3605b5a4dd1 100644 --- a/data/ai/lua/ai_helper.lua +++ b/data/ai/lua/ai_helper.lua @@ -1268,4 +1268,18 @@ function ai_helper.get_attack_combos(units, enemy, cfg) 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 diff --git a/data/ai/lua/generic-recruit_engine.lua b/data/ai/lua/generic-recruit_engine.lua index 359d6d0fccc..70ceb76441b 100644 --- a/data/ai/lua/generic-recruit_engine.lua +++ b/data/ai/lua/generic-recruit_engine.lua @@ -5,27 +5,33 @@ return { -- the CA will use the function names ai_cas:recruit_rushers_eval/exec, so should be referenced by the object name used by the calling AI -- ai_cas also has the functions find_best_recruit, find_best_recruit_hex and analyze_enemy_unit added to it -- find_best_recruit, find_best_recruit_hex may be useful for writing recruitment code separately from the engine - -- score_function: a function that returns the CA score when recruit_rushers_eval wants to recruit - init = function(ai, ai_cas, score_function) - -- default score function if one not provided - if not score_function then - score_function = function() return 300000 end + -- params: parameters to configure recruitment + -- score_function: function that returns the CA score when recruit_rushers_eval wants to recruit + -- (default returns the RCA recruitment score) + -- randomness: a measure of randomness in recruitment + -- higher absolute values increase randomness, with values above about 3 being close to completely random + -- (default = 0.1) + -- min_turn_1_recruit: function that returns true if only enough units to grab nearby villages should be recruited turn 1, false otherwise + -- (default always returns false) + -- leader_takes_village: function that returns true if and only if the leader is going to move to capture a village this turn + -- (default always returns true) + init = function(ai, ai_cas, params) + if not params then + params = {} end + math.randomseed(os.time()) local H = wesnoth.require "lua/helper.lua" local W = H.set_wml_action_metatable {} - local AH = wesnoth.require "~/ai/lua/ai_helper.lua" - - local get_next_id = (function() - local next_id = 0 - return function() - next_id = next_id + 1 - return next_id - end - end)() + local AH = wesnoth.require "ai/lua/ai_helper.lua" + local LS = wesnoth.require "lua/location_set.lua" local recruit_data = {} + local no_village_cost = function(recruit_id) + return wesnoth.unit_types[recruit_id].cost+wesnoth.unit_types[recruit_id].level+wesnoth.sides[wesnoth.current.side].village_gold + end + local get_hp_efficiency = function (table, recruit_id) -- raw durability is a function of hp and the regenerates ability -- efficiency decreases faster than cost increases to avoid recruiting many expensive units @@ -40,7 +46,6 @@ return { type = recruit_id, random_traits = false, name = "X", - id = recruit_id .. get_next_id(), random_gender = false } -- Find the best regeneration ability and use it to estimate hp regained by regeneration @@ -54,10 +59,12 @@ return { end effective_hp = effective_hp + (regen_amount * effective_hp/30) end - local efficiency = math.max(math.log(effective_hp/20),0.01)/(wesnoth.unit_types[recruit_id].cost^2) + local hp_score = math.max(math.log(effective_hp/20),0.01) + local efficiency = hp_score/(wesnoth.unit_types[recruit_id].cost^2) + local no_village_efficiency = hp_score/(no_village_cost(recruit_id)^2) - table[recruit_id] = efficiency - return efficiency + table[recruit_id] = {efficiency, no_village_efficiency} + return {efficiency, no_village_efficiency} end local efficiency = {} setmetatable(efficiency, { __index = get_hp_efficiency }) @@ -81,7 +88,7 @@ return { end function analyze_enemy_unit(enemy_type, ally_type) - local function get_best_attack(attacker, defender, unit_defense, can_poison) + local function get_best_attack(attacker, defender, defender_defense, attacker_defense, can_poison) -- Try to find the average damage for each possible attack and return the one that deals the most damage. -- Would be preferable to call simulate combat, but that requires the defender to be on the map according -- to documentation and we are looking for hypothetical situations so would have to search for available @@ -93,15 +100,17 @@ return { -- In some cases (like poison), this approximation is preferred to the actual value. local best_damage = 0 local best_attack = nil - -- This doesn't actually check for the ability steadfast, but gives correct answer in the default era - -- TODO: find a more reliable method - local steadfast = false -- wesnoth.unit_ability(defender, "resistance") + local best_poison_damage = 0 + -- Steadfast is currently disabled because it biases the AI too much in favour of Guardsmen + -- Basically it sees the defender stats for damage and wrongfully concludes that the unit is amazing + -- This may be rectifiable by looking at retaliation damage as well. + local steadfast = false for attack in H.child_range(wesnoth.unit_types[attacker.type].__cfg, "attack") do - local defense = unit_defense + local defense = defender_defense local poison = false local damage_multiplier = 1 - -- TODO: handle more abilities (charge, drain) + -- TODO: handle more abilities (charge) for special in H.child_range(attack, 'specials') do local mod if H.get_child(special, 'poison') and can_poison then @@ -126,7 +135,7 @@ return { if mod then if mod.backstab then -- Assume backstab happens on only 1/2 of attacks - -- TODO: find out what actual probability is + -- TODO: find out what actual probability of getting to backstab is damage_multiplier = damage_multiplier*(mod.multiply*0.5 + 0.5) end end @@ -138,10 +147,10 @@ return { if (defender_attack.range == attack.range) then for special in H.child_range(defender_attack, 'specials') do if H.get_child(special, 'drains') and living(attacker) then - -- TODO: handle chance to hit + -- TODO: calculate chance to hit -- currently assumes 50% chance to hit using supplied constant local attacker_resistance = wesnoth.unit_resistance(attacker, defender_attack.type) - drain_recovery = (defender_attack.damage*defender_attack.number*attacker_resistance*0.25)/100 + drain_recovery = (defender_attack.damage*defender_attack.number*attacker_resistance*attacker_defense/2)/10000 end end end @@ -170,16 +179,16 @@ return { if poison then -- Add poison damage * probability of poisoning poison_damage = 8*(1-((1-defense)^attack.number)) - attack_damage = attack_damage + poison_damage end - if (not best_attack) or (attack_damage > best_damage) then + if (not best_attack) or (attack_damage+poison_damage > best_damage+best_poison_damage) then best_damage = attack_damage + best_poison_damage = poison_damage best_attack = attack end end - return best_attack, best_damage, poison_damage + return best_attack, best_damage, best_poison_damage end -- Use cached information when possible: this is expensive @@ -199,7 +208,6 @@ return { type = enemy_type, random_traits = false, name = "X", - id = enemy_type .. get_next_id(), random_gender = false } local can_poison = living(unit) or wesnoth.unit_ability(unit, 'regenerate') @@ -210,13 +218,15 @@ return { type = ally_type, random_traits = false, name = "X", - id = ally_type .. get_next_id(), random_gender = false } + local recruit_flat_defense = wesnoth.unit_defense(recruit, "Gt") + local recruit_best_defense = get_best_defense(recruit) + local can_poison_retaliation = living(recruit) or wesnoth.unit_ability(recruit, 'regenerate') - best_flat_attack, best_flat_damage, flat_poison = get_best_attack(recruit, unit, flat_defense, can_poison) - best_high_defense_attack, best_high_defense_damage, high_defense_poison = get_best_attack(recruit, unit, best_defense, can_poison) - best_retaliation, best_retaliation_damage, retaliation_poison = get_best_attack(unit, recruit, wesnoth.unit_defense(recruit, "Gt"), can_poison_retaliation) + best_flat_attack, best_flat_damage, flat_poison = get_best_attack(recruit, unit, flat_defense, recruit_best_defense, can_poison) + best_high_defense_attack, best_high_defense_damage, high_defense_poison = get_best_attack(recruit, unit, best_defense, recruit_flat_defense, can_poison) + best_retaliation, best_retaliation_damage, retaliation_poison = get_best_attack(unit, recruit, recruit_flat_defense, best_defense, can_poison_retaliation) local result = { offense = { attack = best_flat_attack, damage = best_flat_damage, poison_damage = flat_poison }, @@ -242,6 +252,21 @@ return { end function get_hp_ratio_with_gold() + function sum_gold_for_sides(side_filter) + -- sum positive amounts of gold for a set of sides + -- positive only because it is used to estimate the number of enemy units that could appear + -- and negative numbers should't subtract from the number of units on the map + local gold = 0 + local sides = wesnoth.get_sides(side_filter) + for i,s in ipairs(sides) do + if s.gold > 0 then + gold = gold + s.gold + end + end + + return gold + end + -- Hitpoint ratio of own units / enemy units -- Also convert available gold to a hp estimate my_units = AH.get_live_units { @@ -255,13 +280,8 @@ return { for i,u in ipairs(my_units) do my_hp = my_hp + u.hitpoints end for i,u in ipairs(enemies) do enemy_hp = enemy_hp + u.hitpoints end - my_hp = my_hp + wesnoth.sides[wesnoth.current.side].gold*2.3 - local enemy_gold = 0 - local enemies = wesnoth.get_sides {{"enemy_of", {side = wesnoth.current.side} }} - for i,s in ipairs(enemies) do - enemy_gold = enemy_gold + s.gold - end - enemy_hp = enemy_hp+enemy_gold*2.3 + my_hp = my_hp + sum_gold_for_sides({{"allied_with", {side = wesnoth.current.side} }})*2.3 + enemy_hp = enemy_hp+sum_gold_for_sides({{"enemy_of", {side = wesnoth.current.side} }})*2.3 hp_ratio = my_hp/(enemy_hp + 1e-6) return hp_ratio @@ -281,6 +301,7 @@ return { return 0 end + -- Check for space to recruit a unit get_current_castle(leader, data) local no_space = true for i,c in ipairs(data.castle.locs) do @@ -294,11 +315,23 @@ return { return 0 end + -- Check for minimal recruit option + if wesnoth.current.turn == 1 and params.min_turn_1_recruit and params.min_turn_1_recruit() then + if not get_village_target(leader, data)[1] then + return 0 + end + end + if data.recruit == nil then data.recruit = init_data(leader) end data.recruit.cheapest_unit_cost = cheapest_unit_cost - return score_function() + + local score = 180000 -- default score if one not provided. Same as RCA AI + if params.score_function then + score = params.score_function() + end + return score end function init_data(leader) @@ -310,6 +343,7 @@ return { } local enemy_counts = {} local enemy_types = {} + local possible_enemy_recruit_count = 0 local function add_unit_type(unit_type) if enemy_counts[unit_type] == nil then @@ -329,6 +363,7 @@ return { { "enemy_of", {side = wesnoth.current.side} }, { "has_unit", { canrecruit = true }} }) for i, side in ipairs(enemy_sides) do + possible_enemy_recruit_count = possible_enemy_recruit_count + #(wesnoth.sides[side.side].recruit) for j, unit_type in ipairs(wesnoth.sides[side.side].recruit) do add_unit_type(unit_type) end @@ -336,6 +371,7 @@ return { data.enemy_counts = enemy_counts data.enemy_types = enemy_types data.num_enemies = #enemies + data.possible_enemy_recruit_count = possible_enemy_recruit_count return data end @@ -371,33 +407,43 @@ return { local unit_attack_type_count = {} -- The attack types a unit will use local unit_attack_range_count = {} -- The ranges a unit will use local enemy_type_count = 0 + local poisoner_count = 0.1 -- Number of units with a poison attack (set to slightly > 0 because we divide by it later) + local poisonable_count = 0 -- Number of units that the opponents control that are hurt by poison + local recruit_count = {} + for i, recruit_id in ipairs(wesnoth.sides[wesnoth.current.side].recruit) do + recruit_count[recruit_id] = #(AH.get_live_units { side = wesnoth.current.side, type = recruit_id, canrecruit = 'no' }) + end + for i, unit_type in ipairs(enemy_types) do enemy_type_count = enemy_type_count + 1 + local poison_vulnerable = false for i, recruit_id in ipairs(wesnoth.sides[wesnoth.current.side].recruit) do local analysis = analyze_enemy_unit(unit_type, recruit_id) - -- This line should be moved out of the loop! - local recruit_count = #(AH.get_live_units { side = wesnoth.current.side, type = recruit_id, canrecruit = 'no' }) - if recruit_effectiveness[recruit_id] == nil then - recruit_effectiveness[recruit_id] = 0 + recruit_effectiveness[recruit_id] = {damage = 0, poison_damage = 0} recruit_vulnerability[recruit_id] = 0 end - recruit_effectiveness[recruit_id] = recruit_effectiveness[recruit_id] + analysis.defense.damage * enemy_counts[unit_type]^2 + recruit_effectiveness[recruit_id].damage = recruit_effectiveness[recruit_id].damage + analysis.defense.damage * enemy_counts[unit_type]^2 + if analysis.defense.poison_damage and analysis.defense.poison_damage > 0 then + poison_vulnerable = true + recruit_effectiveness[recruit_id].poison_damage = recruit_effectiveness[recruit_id].poison_damage + + analysis.defense.poison_damage * enemy_counts[unit_type]^2 + end recruit_vulnerability[recruit_id] = recruit_vulnerability[recruit_id] + (analysis.retaliation.damage * enemy_counts[unit_type])^3 local attack_type = analysis.defense.attack.type if attack_type_count[attack_type] == nil then attack_type_count[attack_type] = 0 end - attack_type_count[attack_type] = attack_type_count[attack_type] + recruit_count + attack_type_count[attack_type] = attack_type_count[attack_type] + recruit_count[recruit_id] local attack_range = analysis.defense.attack.range if attack_range_count[attack_range] == nil then attack_range_count[attack_range] = 0 end - attack_range_count[attack_range] = attack_range_count[attack_range] + recruit_count + attack_range_count[attack_range] = attack_range_count[attack_range] + recruit_count[recruit_id] if unit_attack_type_count[recruit_id] == nil then unit_attack_type_count[recruit_id] = {} @@ -409,15 +455,30 @@ return { end unit_attack_range_count[recruit_id][attack_range] = true end + if poison_vulnerable then + poisonable_count = poisonable_count + enemy_counts[unit_type] + end end + for i, recruit_id in ipairs(wesnoth.sides[wesnoth.current.side].recruit) do + -- Count the number of units with the poison ability + -- This could be wrong if all the units on the enemy side are immune to poison, but since poison has no effect then anyway it doesn't matter + if recruit_effectiveness[recruit_id].poison_damage > 0 then + poisoner_count = poisoner_count + recruit_count[recruit_id] + end + end + -- Subtract the number of possible recruits for the enemy from the list of poisonable units + -- This works perfectly unless some of the enemy recruits cannot be poisoned (e.g. not_living) + -- However, there is no problem with this since poison is generally less useful in such situations and subtracting them too discourages such recruiting + local poison_modifier = math.max(0, math.min(((poisonable_count-recruit_data.recruit.possible_enemy_recruit_count) / (poisoner_count*5)), 1))^2 for i, recruit_id in ipairs(wesnoth.sides[wesnoth.current.side].recruit) do -- Ensure effectiveness and vulnerability are positive. -- Negative values imply that drain is involved and the amount drained is very high - if recruit_effectiveness[recruit_id] <= 0 then - recruit_effectiveness[recruit_id] = 0.01 + if recruit_effectiveness[recruit_id].damage <= 0 then + recruit_effectiveness[recruit_id].damage = 0.01 else - recruit_effectiveness[recruit_id] = (recruit_effectiveness[recruit_id] / (num_enemies)^2)^0.5 + recruit_effectiveness[recruit_id].damage = (recruit_effectiveness[recruit_id].damage / (num_enemies)^2)^0.5 end + recruit_effectiveness[recruit_id].poison_damage = (recruit_effectiveness[recruit_id].poison_damage / (num_enemies)^2)^0.5 * poison_modifier if recruit_vulnerability[recruit_id] <= 0 then recruit_vulnerability[recruit_id] = 0.01 else @@ -485,7 +546,10 @@ return { get_current_castle(leader, data) local best_hex, village = get_village_target(leader, data) - if not village[1] then + if village[1] then + table.insert(data.castle.assigned_villages_x, village[1]) + table.insert(data.castle.assigned_villages_y, village[2]) + else -- no available village, look for hex closest to enemy leader local max_rating = -1 for i,c in ipairs(data.castle.locs) do @@ -544,37 +608,49 @@ return { end recruit_count = recruit_count / attack_types local recruit_modifier = 1+recruit_count/50 + local efficiency_index = 1 + local unit_cost = wesnoth.unit_types[recruit_id].cost -- Use time to enemy to encourage recruiting fast units when the opponent is far away (game is beginning or we're winning) -- Base distance on - local recruit_unit + local recruit_unit = wesnoth.create_unit { + type = recruit_id, + x = best_hex[1], + y = best_hex[2], + random_traits = false, + name = "X", + random_gender = false + } if target_hex[1] then - recruit_unit = wesnoth.create_unit { - type = recruit_id, - x = target_hex[1], - y = target_hex[2], - random_traits = false, - name = "X", - id = recruit_id .. get_next_id(), - random_gender = false - } - else - recruit_unit = wesnoth.create_unit { - type = recruit_id, - x = best_hex[1], - y = best_hex[2], - random_traits = false, - name = "X", - id = recruit_id .. get_next_id(), - random_gender = false - } - end - local path, cost = wesnoth.find_path(recruit_unit, enemy_location.x, enemy_location.y, {ignore_units = true}) - local move_score = wesnoth.unit_types[recruit_id].max_moves / (cost*wesnoth.unit_types[recruit_id].cost^0.5) + local path, cost = wesnoth.find_path(recruit_unit, target_hex[1], target_hex[2], {viewing_side=0, max_cost=wesnoth.unit_types[recruit_id].max_moves+1}) + if cost > wesnoth.unit_types[recruit_id].max_moves then + -- Unit cost is effectively higher if cannot reach the village + efficiency_index = 2 + unit_cost = no_village_cost(recruit_id) + end + -- Later calculations are based on where the unit will be after initial move + recruit_unit.x = target_hex[1] + recruit_unit.y = target_hex[2] + end + + local path, cost = wesnoth.find_path(recruit_unit, enemy_location.x, enemy_location.y, {ignore_units = true}) + local time_to_enemy = cost / wesnoth.unit_types[recruit_id].max_moves + local move_score = 1 / (time_to_enemy * unit_cost^0.5) + + local eta = math.ceil(time_to_enemy) + if target_hex[1] then + -- expect a 1 turn delay to reach village + eta = eta + 1 + end + -- divide the lawful bonus by eta before running it through the function because the function converts from 0 centered to 1 centered + local lawful_bonus = wesnoth.get_time_of_day(wesnoth.current.turn + eta).lawful_bonus / eta^2 + local damage_bonus = AH.get_unit_time_of_day_bonus(recruit_unit.__cfg.alignment, lawful_bonus) -- Estimate effectiveness on offense and defense - local offense_score = recruit_effectiveness[recruit_id]/(wesnoth.unit_types[recruit_id].cost^0.3*recruit_modifier^4) - local defense_score = efficiency[recruit_id]/recruit_vulnerability[recruit_id] + local offense_score = + (recruit_effectiveness[recruit_id].damage*damage_bonus+recruit_effectiveness[recruit_id].poison_damage) + /(wesnoth.unit_types[recruit_id].cost^0.3*recruit_modifier^4) + local defense_score = efficiency[recruit_id][efficiency_index]/recruit_vulnerability[recruit_id] local unit_score = {offense = offense_score, defense = defense_score, move = move_score} recruit_scores[recruit_id] = unit_score @@ -601,13 +677,14 @@ return { local offense_weight = 2.5 local defense_weight = 1/hp_ratio^0.5 local move_weight = math.max((distance_to_enemy/20)^2, 0.25) + local randomness = params.randomness or 0.1 for i, recruit_id in ipairs(wesnoth.sides[wesnoth.current.side].recruit) do local scores = recruit_scores[recruit_id] local offense_score = (scores["offense"]/best_scores["offense"])^0.5 local defense_score = (scores["defense"]/best_scores["defense"])^0.5 local move_score = (scores["move"]/best_scores["move"])^0.5 - local bonus = 0 + local bonus = math.random()*randomness if scores["slows"] then bonus = bonus + 0.4 end @@ -626,10 +703,11 @@ return { recruitable_units[recruit_id].y = best_hex[2] local path, cost = wesnoth.find_path(recruitable_units[recruit_id], target_hex[1], target_hex[2], {viewing_side=0, max_cost=wesnoth.unit_types[recruit_id].max_moves+1}) if cost > wesnoth.unit_types[recruit_id].max_moves then - -- large penalty if the unit can't reach the target village - bonus = bonus - 1 + -- penalty if the unit can't reach the target village + bonus = bonus - 0.2 end end + local score = offense_score*offense_weight + defense_score*defense_weight + move_score*move_weight + bonus if AH.print_exec() then @@ -672,12 +750,11 @@ return { -- TODO get list of villages not owned by allies instead -- this may have false positives (villages that can't be reached due to difficult/impassible terrain) local exclude_x, exclude_y = "0", "0" - if data.castle.assigned_villages_x ~= nil then + if data.castle.assigned_villages_x ~= nil and data.castle.assigned_villages_x[1] then exclude_x = table.concat(data.castle.assigned_villages_x, ",") exclude_y = table.concat(data.castle.assigned_villages_y, ",") end - local villages = wesnoth.get_locations { - terrain = '*^V*', + local villages = wesnoth.get_villages { owner_side = 0, { "and", { radius = fastest_unit_speed, @@ -694,13 +771,17 @@ return { if not data.castle.assigned_villages_x then data.castle.assigned_villages_x = {} data.castle.assigned_villages_y = {} - for i,v in ipairs(villages) do - local path, cost = wesnoth.find_path(leader, v[1], v[2]) - if cost <= leader.max_moves then - table.insert(data.castle.assigned_villages_x, v[1]) - table.insert(data.castle.assigned_villages_y, v[2]) - table.remove(villages, i) - break + + if not params.leader_takes_village or params.leader_takes_village() then + -- skip one village for the leader + for i,v in ipairs(villages) do + local path, cost = wesnoth.find_path(leader, v[1], v[2]) + if cost <= leader.max_moves then + table.insert(data.castle.assigned_villages_x, v[1]) + table.insert(data.castle.assigned_villages_y, v[2]) + table.remove(villages, i) + break + end end end end @@ -708,6 +789,14 @@ return { local village_count = #villages local test_units = get_test_units() local num_recruits = #test_units + local total_village_distance = {} + for j,c in ipairs(data.castle.locs) do + c_index = c[1] + c[2]*1000 + total_village_distance[c_index] = 0 + for i,v in ipairs(villages) do + total_village_distance[c_index] = total_village_distance[c_index] + H.distance_between(c[1], c[2], v[1], v[2]) + end + end local width,height,border = wesnoth.get_map_size() for i,v in ipairs(villages) do @@ -740,7 +829,10 @@ return { end distance = distance / num_recruits - if distance < village_shortest_distance then + if distance < village_shortest_distance + or (distance == village_shortest_distance and distance < AH.no_path + and total_village_distance[c[1] + c[2]*1000] > total_village_distance[village_best_hex[1]+village_best_hex[2]*1000]) + then village_best_hex = c village_shortest_distance = distance end @@ -751,6 +843,7 @@ return { target = v shortest_distance = village_shortest_distance end + if not viable_village then -- this village could not be reached by any unit -- eliminate it from consideration @@ -760,8 +853,6 @@ return { end end - table.insert(data.castle.assigned_villages_x, target[1]) - table.insert(data.castle.assigned_villages_y, target[2]) data.castle.loose_gold_limit = math.floor(wesnoth.sides[wesnoth.current.side].gold/village_count + 0.5) return hex, target @@ -775,7 +866,7 @@ return { local movetype = wesnoth.unit_types[id].__cfg.movement_type if custom_movement or (not movetypes[movetype]) - or (movetypes[movetype] > wesnoth.unit_types[id].max_moves) + or (movetypes[movetype] < wesnoth.unit_types[id].max_moves) then if not custom_movement then movetypes[movetype] = wesnoth.unit_types[id].max_moves @@ -786,7 +877,6 @@ return { side = wesnoth.current.side, random_traits = false, name = "X", - id = id .. get_next_id(), random_gender = false }) end diff --git a/data/ai/lua/generic-rush_engine.lua b/data/ai/lua/generic-rush_engine.lua index 95c174e51b0..05fad3f690e 100644 --- a/data/ai/lua/generic-rush_engine.lua +++ b/data/ai/lua/generic-rush_engine.lua @@ -34,7 +34,22 @@ return { ------- Recruit CA -------------- - wesnoth.require("ai/lua/generic-recruit_engine.lua").init(ai, generic_rush) + local params = { + score_function = (function() return 300000 end), + min_turn_1_recruit = (function() return generic_rush:castle_switch_eval() > 0 end), + leader_takes_village = (function() + if generic_rush:castle_switch_eval() > 0 then + local take_village = #(wesnoth.get_villages { + x = generic_rush.data.leader_target[1], + y = generic_rush.data.leader_target[2] + }) > 0 + return take_village + end + return true + end + ) + } + wesnoth.require("ai/lua/generic-recruit_engine.lua").init(ai, generic_rush, params) -------- Castle Switch CA -------------- @@ -58,6 +73,10 @@ return { return 0 end + if self.data.leader_target then + return 290000 + end + local width,height,border = wesnoth.get_map_size() local keeps = wesnoth.get_locations { terrain = "K*^*,*^Kov", -- Keeps @@ -71,7 +90,10 @@ return { x = leader.x, y = leader.y, terrain = "K*^*,*^Kov", radius = 2, { "filter_radius", { terrain = 'C*^*,K*^*,*^Kov,*^Cov' } } - }} -- That are not close and connected to a keep the leader is on + }}, -- That are not close and connected to a keep the leader is on + { "filter_adjacent_location", { + terrain = 'C*^*,K*^*,*^Kov,*^Cov' + }} -- That are not one-hex keeps } if #keeps < 1 then -- Skip if there aren't extra keeps to evaluate @@ -85,14 +107,14 @@ return { } -- Look for the best keep - local best_score, best_loc = 0, {} + local best_score, best_loc, best_turns = 0, {}, 3 for i,loc in ipairs(keeps) do - -- Only consider keeps within 3 turns movement + -- Only consider keeps within 2 turns movement local path, cost = wesnoth.find_path(leader, loc[1], loc[2]) local score = 0 -- Prefer closer keeps to enemy - local turns = cost/leader.max_moves - if turns <= 2 and turns > 0 then + local turns = math.ceil(cost/leader.max_moves) + if turns <= 2 then score = 1/(math.ceil(turns)) for j,e in ipairs(enemy_leaders) do score = score + 1 / H.distance_between(loc[1], loc[2], e.x, e.y) @@ -101,12 +123,40 @@ return { if score > best_score then best_score = score best_loc = loc + best_turns = turns end end end if best_score > 0 then - self.data.target_keep = best_loc + local next_hop = AH.next_hop(leader, best_loc[1], best_loc[2]) + + 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.get_villages( { + { "and", { x = next_hop[1], y = next_hop[2], radius = 3 }}, + owner_side = 0 }) + local cheapest_unit_cost = AH.get_cheapest_recruit_cost() + for i,loc in ipairs(close_villages) do + local path_village, cost_village = wesnoth.find_path(leader, loc[1], loc[2]) + if cost_village <= leader.moves then + local dummy_leader = wesnoth.copy_unit(leader) + dummy_leader.x = loc[1] + dummy_leader.y = loc[2] + local path_keep, cost_keep = wesnoth.find_path(dummy_leader, best_loc[1], best_loc[2]) + 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 + + self.data.leader_target = next_hop AH.done_eval_messages(start_time, ca_name) return 290000 end @@ -121,38 +171,8 @@ return { if AH.print_exec() then print(' ' .. os.clock() .. ' Executing castle_switch CA') end if AH.show_messages() then W.message { speaker = leader.id, message = 'Switching castles' } end - local x, y = self.data.target_keep[1], self.data.target_keep[2] - local next_hop = AH.next_hop(leader, x, y) - if next_hop and ((next_hop[1] ~= leader.x) or (next_hop[2] ~= leader.y)) then - local path, cost = wesnoth.find_path(leader, x, y) - local turn_cost = math.ceil(cost/leader.max_moves) - - -- See if there is a nearby village that can be captured without delaying progress - local close_villages = wesnoth.get_locations { - { "and", { x = next_hop[1], y = next_hop[2], radius = 3 }}, - terrain = "*^V*", - owner_side = 0 } - local cheapest_unit_cost = AH.get_cheapest_recruit_cost() - for i,loc in ipairs(close_villages) do - local path_village, cost_village = wesnoth.find_path(leader, loc[1], loc[2]) - if cost_village <= leader.moves then - local dummy_leader = wesnoth.copy_unit(leader) - dummy_leader.x = loc[1] - dummy_leader.y = loc[2] - local path_keep, cost_keep = wesnoth.find_path(dummy_leader, x, y) - local turns_from_keep = math.ceil(cost_keep/leader.max_moves) - if turns_from_keep < turn_cost - 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 - - ai.move(leader, next_hop[1], next_hop[2]) - end + ai.move(leader, self.data.leader_target[1], self.data.leader_target[2]) + self.data.leader_target = nil end ------- Grab Villages CA -------------- @@ -174,7 +194,7 @@ return { { "filter_side", {{"enemy_of", {side = wesnoth.current.side} }} } } - local villages = wesnoth.get_locations { terrain = '*^V*' } + local villages = wesnoth.get_villages() -- Just in case: if (not villages[1]) then AH.done_eval_messages(start_time, ca_name) @@ -329,8 +349,8 @@ return { -- For now, we also simply don't poison units on villages (unless standard combat CA does it) local on_village = wesnoth.get_terrain_info(wesnoth.get_terrain(defender.x, defender.y)).village - -- Also, poisoning units that would level up through the attack is very bad - local about_to_level = defender.max_experience - defender.experience <= wesnoth.unit_types[attacker.type].level + -- Also, poisoning units that would level up through the attack or could level on their turn as a result is very bad + local about_to_level = defender.max_experience - defender.experience <= (wesnoth.unit_types[attacker.type].level * 2) if (not cant_poison) and (not on_village) and (not about_to_level) then -- Strongest enemy gets poisoned first @@ -344,13 +364,17 @@ return { -- More priority to enemies on strong terrain local defender_defense = 100 - wesnoth.unit_defense(defender, wesnoth.get_terrain(defender.x, defender.y)) - rating = rating + defender_defense / 2. + rating = rating + defender_defense / 4. -- For the same attacker/defender pair, go to strongest terrain local attack_defense = 100 - wesnoth.unit_defense(attacker, wesnoth.get_terrain(a.dst.x, a.dst.y)) - rating = rating + attack_defense / 100. + rating = rating + attack_defense / 2. --print('rating', rating) + -- And from village everything else being equal + local is_village = wesnoth.get_terrain_info(wesnoth.get_terrain(a.dst.x, a.dst.y)).village + if is_village then rating = rating + 0.5 end + if rating > max_rating then max_rating, best_attack = rating, a end diff --git a/players_changelog b/players_changelog index 44b80569985..ee748146d0b 100644 --- a/players_changelog +++ b/players_changelog @@ -3,6 +3,12 @@ changes may be omitted). For a complete list of changes, see the main changelog: http://svn.gna.org/viewcvs/*checkout*/wesnoth/trunk/changelog Version 1.11.1+svn: + * AI: + * Experimental Multiplayer AI + * Improve recruitment, notably first turn choices and units with poison. + * Improve selection of units for village stealing. + * Remove dependency on AI-demos add-on. + * Language and i18n: * Updated translations: Italian, Portuguese.