From 2d95c0f7d3f56af932716729cdbba0377bdd8cae Mon Sep 17 00:00:00 2001 From: Celtic Minstrel Date: Sun, 15 Aug 2021 22:42:48 -0400 Subject: [PATCH] AI: Convert the FormulaAI example scenario to do all the same things using Lua - Unit formulas are replaced by inline MicroAIs or candidate actions placed in the unit's [ai] tag. - The stationed_guardian MicroAI was chosen as the closest match to the guardian FormulaAI. It's not a perfect fit, but it's pretty close. - The goto and patrol MicroAIs are fairly obvious substitutes for the respective unit formulas. - The priority test in unit formulas is replaced by fairly basic inline non-external CAs with differing scores. - The side formulas (opening.fai) have been converted to a separate Lua stage using a new opening.lua. However, that's only a partial conversion. The move and attack functionalities of opening.fai are missing from opening.lua; instead the built-in move, move leader to keep, and combat CAs are used. - The scouting FormulaAI CA has been ported to Lua. It remains a very basic AI, probably not well-suited to genera use. - The level up attack FormulaAI CA has been ported to Lua. Like the new scouting CA, this is mostly intended to serve as an example. --- data/ai/lua/ca_level_up_attack.lua | 91 +++++ data/ai/lua/ca_simple_scouting.lua | 61 ++++ data/ai/lua/opening.lua | 31 ++ .../scenario-lua_ai_unit_actions.cfg | 319 ++++++++++++++++++ 4 files changed, 502 insertions(+) create mode 100644 data/ai/lua/ca_level_up_attack.lua create mode 100644 data/ai/lua/ca_simple_scouting.lua create mode 100644 data/ai/lua/opening.lua create mode 100644 data/ai/scenarios/scenario-lua_ai_unit_actions.cfg diff --git a/data/ai/lua/ca_level_up_attack.lua b/data/ai/lua/ca_level_up_attack.lua new file mode 100644 index 00000000000..8b2b08cc955 --- /dev/null +++ b/data/ai/lua/ca_level_up_attack.lua @@ -0,0 +1,91 @@ +-- An example CA that tries to level up units by attacking weakened enemies. +-- Ported from level_up_attack_eval.fai and level_up_attack_move.fai + +local LS = wesnoth.require "location_set" +local F = wesnoth.require "functional" +local level_up_attack = {} + +local function kill_xp(unit) + local ratio = unit.level + if ratio == 0 then ratio = 0.5 end + return wesnoth.game_config.kill_experience * ratio +end + +local function get_best_defense_loc(moves, attacker, victim) + local attack_spots = F.filter(moves, function(v) + return wesnoth.map.distance_between(v, victim) == 1 + end) + return F.choose(attack_spots, function(loc) + return attacker:defense_on(loc) + end) +end + +local function iter_possible_targets(moves, attacker) + moves = LS.of_pairs(moves) + -- The criteria are: a) unit is reachable b) unit's health is low + local targets = wesnoth.units.find({ + + }) + return coroutine.wrap(function() + local checked = LS.create() + moves:iter(function(to_x, to_y) + for adj_x, adj_y in wesnoth.current.map:iter_adjacent(to_x, to_y) do + if not checked:get(adj_x, adj_y) then + checked:insert(adj_x, adj_y) + local u = wesnoth.units.get(adj_x, adj_y) + if u and u.hitpoints / u.max_hitpoints < 0.2 then + coroutine.yield(u) + end + end + end + end) + end) +end + +local possible_attacks + +function level_up_attack:evaluation(cfg, data, filter_own) + possible_attacks = LS.create() + local moves = LS.of_raw(ai.get_src_dst()) + local units = wesnoth.units.find(filter_own) + for _,me in ipairs(units) do + local save_x, save_y = me.x, me.y + if not moves[me] or #moves[me] == 0 then + goto continue + end + if kill_xp(me) <= (me.max_experience - me.experience) then + goto continue + end + for target in iter_possible_targets(moves[me], me) do + local defense_loc = get_best_defense_loc(moves[me], me, target) + me:to_map(defense_loc.x, defense_loc.y) + local attacker_outcome, defender_outcome = wesnoth.simulate_combat(me, target) + -- Only consider attacks where + -- a) there's a chance the defender dies and + -- b) there's no chance the attacker dies + if defender_outcome.hp_chance[0] == 0 or attacker_outcome.hp_chance[0] > 0 then + goto continue + end + -- If killing the defender is the most likely result, save this as a possible attack + local best = F.choose_map(defender_outcome.hp_chance, function(k, v) return v end) + if best.key == 0 then + possible_attacks:insert(defense_loc, { + chance = defender_outcome.hp_chance[0], + attacker = me, target = target + }) + end + end + ::continue:: + me:to_map(save_x, save_y) + end + local _, best_score = F.choose_map(possible_attacks:to_map(), 'chance') + return math.max(0, best_score) * 100000 +end + +function level_up_attack:execution(cfg, data) + local best_attack = F.choose_map(possible_attacks:to_map(), 'chance') + ai.move(best_attack.value.attacker, best_attack.key) + ai.attack(best_attack.value.attacker, best_attack.value.target) +end + +return level_up_attack diff --git a/data/ai/lua/ca_simple_scouting.lua b/data/ai/lua/ca_simple_scouting.lua new file mode 100644 index 00000000000..6c75bc594ae --- /dev/null +++ b/data/ai/lua/ca_simple_scouting.lua @@ -0,0 +1,61 @@ +-- An example CA that tries to uncover shrouded areas of the map +-- This is a very simple and naive scouting algorithm. +-- It often does stupid things like sending one unit to a village on one turn, +-- but then changing its mind and sending a different unit to that village on the next turn. +-- Or sending a faraway unit instead of a close unit to a given village. + +local LS = wesnoth.require "location_set" +local F = wesnoth.require "functional" +local AH = wesnoth.require "ai/lua/ai_helper" +local simple_scouting = {} + +local possible_scouts, shroud + +function simple_scouting:evaluation(cfg, data, filter_own) + shroud = LS.of_pairs(wesnoth.map.find{ + include_borders = false, + wml.tag.filter_vision{ + visible = false, + respect_fog = false, + side = ai.side, + } + }) + if shroud:size() == 0 then return 0 end + possible_scouts = wesnoth.units.find{ + side = ai.side, + canrecruit = false, + formula = "max_moves > 5 and moves > 0", + wml.tag["and"](filter_own) + } + if #possible_scouts == 0 then return 0 end + return 30000 +end + +function simple_scouting:execution(cfg, data, filter_own) + local villages = LS.of_pairs(wesnoth.map.find{owner_side = 0, gives_income = true}) + while villages:size() > 0 and #possible_scouts > 0 do + local current_scout = table.remove(possible_scouts) + local best = F.choose(villages:to_pairs(), function(loc) + return -wesnoth.map.distance_between(loc, current_scout) + end) + if not best then + table.insert(possible_scouts, current_scout) + break + end + local hop = AH.next_hop(current_scout, best.x, best.y) + ai.move(current_scout, hop) + villages:remove(best) + end + while shroud:size() > 0 and #possible_scouts > 0 do + local current_scout = table.remove(possible_scouts) + local best = F.choose(shroud:to_pairs(), function(loc) + return -wesnoth.map.distance_between(loc, current_scout) + end) + if not best then break end + local hop = AH.next_hop(current_scout, best.x, best.y) + ai.move(current_scout, hop) + shroud:remove(best) + end +end + +return simple_scouting \ No newline at end of file diff --git a/data/ai/lua/opening.lua b/data/ai/lua/opening.lua new file mode 100644 index 00000000000..1c3af06c9f8 --- /dev/null +++ b/data/ai/lua/opening.lua @@ -0,0 +1,31 @@ +-- An extremely simplistic example of an AI that does certain fixed moves +-- It's meant to be used as a custom stage preceding the RCA stage +-- This is a highly specific example and probably not suitable to be directly used in other places + +if wesnoth.current.turn == 1 then + ai.recruit('Skeleton Archer', 11,21) + ai.recruit('Dark Adept', 11,22) + ai.recruit('Dark Adept', 10,22) + ai.recruit('Skeleton Archer', 9,22) + ai.recruit('Ghost', 11,24) + ai.move(11,23, 14,22) +elseif wesnoth.current.turn == 2 then + ai.move(11,21, 13,17) + ai.move(11,22, 13,18) + ai.move(10,22, 7,19) + ai.move(9,22, 4,22) + ai.move(11,24, 18,24) + ai.move(14,22, 11,23) + ai.recruit('Dark Adept', 11,21) + ai.recruit('Dark Adept', 11,22) +elseif wesnoth.current.turn == 3 then + ai.move(18,24, 20,22) + ai.move(15,19, 17,17) + ai.move(4,22, 5,18) + ai.recruit('Skeleton Archer', 11,21) +elseif wesnoth.current.turn == 4 then + ai.recruit('Skeleton Archer', 11,21) +else + ai.recruit('Skeleton Archer', 11,21) + ai.recruit('Dark Adept', 11,22) +end diff --git a/data/ai/scenarios/scenario-lua_ai_unit_actions.cfg b/data/ai/scenarios/scenario-lua_ai_unit_actions.cfg new file mode 100644 index 00000000000..41101e491ea --- /dev/null +++ b/data/ai/scenarios/scenario-lua_ai_unit_actions.cfg @@ -0,0 +1,319 @@ +#textdomain wesnoth-test +# @file data/scenario-test.cfg + +[test] + name=_"Test scenario" + map_data=" +Hh , Hh , Gg , Wwf , Wwf , Gs^Fp , Mm , Hh , Gg , Gs^Fp , Gg , Hh , Gg , Mm , Hh , Mm , Wwf , Wwf , Hh , Gs^Fp , Hh , Mm , Mm +Hh , Hh , Gg^Ve , Wwf , Wwf , Gs^Fp , Mm , Hh , Gg , Gs^Fp , Gg , Hh , Gg , Mm , Hh , Mm , Wwf , Wwf , Hh , Gs^Fp , Hh , Mm , Mm +Wwf , Wwf , Wwf , Wwf , Gg , Wwf , Wwf , Hh , Gg , Gg , Wwf , Ch , Wwf , Gs^Fp , Wwf , Wwf , Re , Re , Hh , Mm , Wwf , Mm , Mm +Mm , Mm , Wwf , Gs^Fp , Gg^Vh , Wwf , Gg , Gg , Wwf , Wwf , Wwf , 1 Kh , Ch , Wwf , Re , Re , Rd , Rd , Wwf , Wwf , Gs^Fp , Wwf , Wwf +Wwf , Wwf , Mm , Wwf , Gs^Fp , Wwf , Wwf , Wwf , Gg^Vh , Gg , Wwf , Ch , Wwf , Ch , Rd , Rd , Wwf , Wwf , Gg^Vh , Gs^Fp , Re^Gvs , Hh , Hh +Hh , Hh , Wwf , Gs^Fp , Wwf , Wwf , Gg , Gg , Gg , Gg , Wwf , Ch , Gg , Wwf , Wwf , Wwf , Mm , Gs^Fp , Re , Re^Gvs , Gg^Wm , Re^Gvs , Re^Gvs +Wwf , Wwf , Mm , Wwf , Hh , Gs^Fp , Rd , Rd , Gg , Gg , Wwf , Wwf , Gs^Fp , Gg , Hh , Gg , Re , Re , Rd , Rd , Gg , Hh , Hh +Hh , Hh , Gs^Fp , Gg , Gg , Rd , Gg , Gg , Wwf , Wwf , Gs^Fp , Wwf , Gs^Fp , Mm , Re , Re , Rd , Rd , Gg , Gg^Efm , Mm , Gs^Fp , Gs^Fp +Gs^Fp , Gs^Fp , Gg , Gg , Wwf , Gg , Wwf , Wwf , Mm , Hh , Wwf , Wwf , Re , Re , Rd , Rd , Rd , Gg , Gs^Fp , Gs^Fp , Gs^Fp , Hh , Hh +Hh , Hh , Wwf , Wwf , Hh , Wwf , Gg , Gg , Gg , Gg , Wwf , Re , Re , Rd , Gg , Gg , Gg , Gg^Vh , Hh , Gg , Wwf , Gg^Efm , Gg^Efm +Wwf , Wwf , Hh , Gg^Efm , Gs^Fp , Hh^Vhh , Gg , Gg , Gg , Ss^Vhs , Hh , Ww , Gs^Fp , Gg , Gs^Fp , Hh , Wwf , Wwf , Wwf , Wwf , Gg , Wwf , Wwf +Hh , Hh , Gg , Gg , Re , Gg , Re , Re , Gg , Ss , Gs^Fp , Ww , Hh , Mm , Ww , Wwf , Gg , Gg , Ds , Gg , Gg , Gs^Fp , Gs^Fp +Gs^Fp , Gs^Fp , Gg , Rd , Rd , Re , Rd , Re , Hh , Mm , Wwf , Ww , Ww , Ww , Gg , Gg , Hh , Gs^Fp , Rd , Rd , Hh , Gg , Gg +Rd , Rd , Gs^Fp , Hh , Rd , Rd , Gs^Fp , Re , Gg , Gg , Wwf , Gg , Wwf , Gg , Gg , Re , Gs^Fp , Hh , Rd , Mm , Gs^Fp , Rd , Rd +Rd , Rd , Hh , Mm , Rd , Hh , Hh , Re , Gg , Gg , Ww , Gg , Wwf , Gg , Hh , Re , Rd , Rd , Rd , Hh , Gg , Rd , Rd +Gg , Gg , Gg , Rd , Ds , Gs^Fp , Gg , Gg , Ww , Ww , Hh , Ww , Gs^Fp , Mm , Gg , Re , Re , Re , Re , Rd , Gg , Gs^Fp , Gs^Fp +Gs^Fp , Gs^Fp , Gg , Gg , Wwf , Gg , Wwf , Wwf , Gs^Fp , Mm , Gs^Fp , Ww , Hh , Ss , Gg , Re , Gg , Gg , Gs^Fp , Gg , Hh , Hh , Hh +Wwf , Wwf , Wwf , Wwf , Hh , Wwf , Gg , Hh , Gg , Gg , Re , Ww , Wwf , Ss^Vhs , Gg , Gg , Gg , Hh^Vhh , Hh , Gg^Efm , Wwf , Wwf , Wwf +Gg^Efm , Gg^Efm , Gs^Fp , Gg , Gs^Fp , Gg^Vh , Rd , Gg , Rd , Rd , Re , Re , Wwf , Gg , Mm , Gg , Wwf , Wwf , Wwf , Wwf , Gg , Mm , Mm +Hh , Hh , Mm , Gs^Fp , Gg , Gg , Rd , Rd , Re , Re , Gs^Fp , Wwf , Gs^Fp , Hh , Wwf , Wwf , Gg , Gg , Gg , Gg , Gs^Fp , Gs^Fp , Gs^Fp +Gs^Fp , Gs^Fp , Gg , Gg^Efm , Rd , Rd , Re , Re , Hh , Mm , Gg , Wwf , Wwf , Wwf , Gg , Gg , Rd , Rd , Hh , Gg , Mm , Hh , Hh +Hh , Hh , Gg^Wm , Rd , Re , Re , Mm , Gg , Wwf , Wwf , Wwf , Ch , Gg , Gg , Gg , Rd , Gg , Gs^Fp , Wwf , Wwf , Wwf , Wwf , Wwf +Re^Gvs , Re^Gvs , Re^Gvs , Re^Gvs , Gg^Vh , Gs^Fp , Wwf , Wwf , Rd , Ch , Ch , Ch , Gg , Gg , Gg^Vh , Gg , Wwf , Wwf , Gs^Fp , Gs^Fp , Gg^Ve , Gg , Gg +Hh , Hh , Gs^Fp , Gs^Fp , Wwf , Wwf , Rd , Rd , Re , Re , Wwf , 2 Kh , Wwf , Gg , Wwf , Wwf , Gg , Wwf , Wwf , Wwf , Wwf , Gs^Fp , Gs^Fp +Gs^Fp , Gs^Fp , Wwf , Wwf , Mm , Rd , Gs^Fp , Hh , Wwf , Wwf , Gg , Ch , Gg , Wwf , Hh , Gg , Wwf , Wwf , Gg^Vh , Gg , Wwf , Mm , Mm +Gs^Fp , Gs^Fp , Wwf , Wwf , Mm , Rd , Gs^Fp , Wwf , Wwf , Gg , Gg , Gg , Gg , Gg , Hh , Gg , Wwf , Wwf , Gg , Gg , Wwf , Mm , Mm +" + + turns=90 + id=unit_actions + + {DEFAULT_SCHEDULE} + + [label] + x,y=16,5 + text=_"Patrol waypoint 1" + [/label] + + [label] + x,y=16,15 + text=_"Patrol waypoint 2" + [/label] + + [label] + x,y=3,14 + text=_"Priorities test" + [/label] + + [label] + x,y=2,12 + text=_"first" + [/label] + + [label] + x,y=3,11 + text=_"second" + [/label] + + [label] + x,y=3,13 + text=_"third" + [/label] + + [label] + x,y=8,5 + text=_"Location guarded (range = 3)" + [/label] + + [side] + type=Dwarvish Steelclad + id=side_1_leader + side=1 + canrecruit=yes + recruit=Dwarvish Guardsman,Dwarvish Fighter,Dwarvish Thunderer,Thief,Poacher,Footpad + gold=200 + controller=human + suppress_end_turn_confirmation=yes + [unit] + x,y=10,8 + type="Elvish Archer" + hitpoints=1 + generate_name=yes + [/unit] + + [unit] + x,y=3,12 + type="Elvish Fighter" + random_traits=no + generate_name=yes + [modifications] + [trait] + id=move + [effect] + apply_to=movement + set=0 + [/effect] + [/trait] + [trait] + id=hp + [effect] + apply_to=hitpoints + increase_total=120 + [/effect] + [/trait] + [/modifications] + [/unit] + [village] + x,y=2,1 + [/village] + [village] + x,y=4,3 + [/village] + [village] + x,y=8,4 + [/village] + [village] + x,y=18,4 + [/village] + [/side] + + [side] + #controller=human + suppress_end_turn_confirmation=yes + name=_"AI" + type=Dark Sorcerer + side=2 + canrecruit=yes + recruit=Skeleton,Skeleton Archer,Walking Corpse,Ghost,Vampire Bat,Dark Adept,Ghoul + gold=200 + shroud=yes + + [unit] + x,y=8,5 + type="Orcish Archer" + generate_name=yes + [ai] + [micro_ai] + ai_type=stationed_guardian + station_x,station_y=8,5 + distance=3 + [/micro_ai] + [/ai] + [/unit] + [unit] + x,y=3,8 + type="Walking Corpse" + generate_name=yes + [ai] + [micro_ai] + ai_type=goto + [filter_location] + formula=castle + [/filter_location] + release_unit_at_goal=yes + [/micro_ai] + [/ai] + [/unit] + + [unit] + x,y=16,5 + type="Wolf Rider" + generate_name=yes + [ai] + [micro_ai] + ai_type=patrol + waypoint_x=16,16 + waypoint_y=5,15 + attack_range=3 + [/micro_ai] + [/ai] + [/unit] + + [unit] + x,y=3,11 + type="Goblin Spearman" + generate_name=yes + [ai] + [candidate_action] + engine=lua + max_score=10000010 + evaluation=<< + local u = wesnoth.units.find(select(4, ...))[1] + if not u then return 0 end + return ai.check_attack(u,3,12).ok and 10000010 or 0 + >> + execution=<< + local u = wesnoth.units.find(select(4, ...))[1] + ai.attack(u,3,12) + >> + [/candidate_action] + [/ai] + [/unit] + + [unit] + x,y=3,13 + type="Goblin Spearman" + generate_name=yes + [ai] + [candidate_action] + engine=lua + max_score=10000009 + evaluation=<< + local u = wesnoth.units.find(select(4, ...))[1] + if not u then return 0 end + return ai.check_attack(u,3,12).ok and 10000009 or 0 + >> + execution=<< + local u = wesnoth.units.find(select(4, ...))[1] + ai.attack(u,3,12) + >> + [/candidate_action] + [/ai] + [/unit] + + [unit] + x,y=2,12 + type="Goblin Spearman" + generate_name=yes + [ai] + [candidate_action] + engine=lua + max_score=10000011 + evaluation=<< + local u = wesnoth.units.find(select(4, ...))[1] + if not u then return 0 end + return ai.check_attack(u,3,12).ok and 10000011 or 0 + >> + execution=<< + local u = wesnoth.units.find(select(4, ...))[1] + ai.attack(u,3,12) + >> + [/candidate_action] + [/ai] + [/unit] + + [unit] + x,y=7,20 + type="Silver Mage" + generate_name=yes + [/unit] + + [unit] + x,y=6,20 + type="Ghost" + generate_name=yes + [/unit] + [unit] + x,y=15,22 + type="Ghost" + generate_name=yes + [/unit] + [unit] + x,y=12,19 + type="Ghost" + generate_name=yes + [/unit] + [unit] + x,y=10,6 + type="Lich" + experience=149 + generate_name=yes + [/unit] + + [ai] + [stage] + engine=lua + code="wesnoth.dofile 'ai/lua/opening.lua'" + [/stage] + + [stage] + id=main_loop + name=ai_default_rca::candidate_action_evaluation_loop + [candidate_action] + engine=cpp + name=ai_default_rca::move_leader_to_keep_phase + max_score = 60000 + score = 60000 + [/candidate_action] + [candidate_action] + engine=cpp + name=ai_default_rca::move_to_targets_phase + max_score = 20000 + score = 20000 + [/candidate_action] + [candidate_action] + engine=cpp + name=ai_default_rca::combat_phase + max_score = 20000 + score = 20000 + [/candidate_action] + [candidate_action] + engine=lua + name=scouting + location="ai/lua/ca_simple_scouting.lua" + max_score = 30000 + [/candidate_action] + [candidate_action] + engine=lua + name=level_up_attack + location = "ai/lua/ca_level_up_attack.lua" + max_score = 100000 + [/candidate_action] + [/stage] + + [stage] + # This exists so you can see the AI's shroud. + engine=lua + name=fallback + code="ai.fallback_human()" + [/stage] + [/ai] + [/side] +[/test]