From 0f8cec56219b55f6a5b4aba60c19a2e5fbd7aea3 Mon Sep 17 00:00:00 2001 From: Nicolas Continanza Date: Tue, 4 Jun 2024 19:27:19 -0300 Subject: [PATCH] [GH-404] lowest and highest stat targeting strategies (#647) * WIP: lowest and highest strategy with tests * WIP: lowest and highest strategy tests * Finish implementing tests. Fix some minor issues with the implementation * Fix bug and test * Fix merge conflict * Support multiple targets * Improve tests naming * Revert unnecessary atom to string conversion * Randomly choose units when two or more have same stat amount * Simplify code * Refactor sorting function * Remove duplicate code * improve pattern-matching for the choose_targets_by_strategy function * improve pattern-matching for all the choose_targets_by_strategy functions * Fix format * Improve function naming --- .../lib/champions/battle/simulator.ex | 119 ++++++++--- apps/champions/test/battle_test.exs | 201 ++++++++++++++++++ .../targeting_strategies/target_strategy.ex | 4 +- 3 files changed, 297 insertions(+), 27 deletions(-) diff --git a/apps/champions/lib/champions/battle/simulator.ex b/apps/champions/lib/champions/battle/simulator.ex index b17751988..ce2ef6217 100644 --- a/apps/champions/lib/champions/battle/simulator.ex +++ b/apps/champions/lib/champions/battle/simulator.ex @@ -24,11 +24,11 @@ defmodule Champions.Battle.Simulator do [x] Frontline - Heroes in slots 1 and 2 [x] Backline - Heroes in slots 2 to 4 [x] All - [ ] Self + [x] Self [ ] Factions [ ] Classes - [ ] Min (STAT) - [ ] Max (STAT) + [x] Lowest (STAT) + [x] Highest (STAT) It can also be chosen how many targets are affected by the effect, and if they are allies or enemies. @@ -654,49 +654,49 @@ defmodule Champions.Battle.Simulator do # Choose the targets for an effect with "random" as the strategy. Returns the target ids. # The `== target_allies` works as a negation operation when `target_allies` is `false`, and does nothing when `true`. - defp choose_targets_by_strategy(caster, %{count: count, type: "random", target_allies: target_allies}, state), + defp choose_targets_by_strategy(caster, %{type: "random"} = targeting_strategy, state), do: state.units - |> Enum.filter(fn {_id, unit} -> unit.team == caster.team == target_allies end) - |> Enum.take_random(count) + |> Enum.filter(fn {_id, unit} -> unit.team == caster.team == targeting_strategy.target_allies end) + |> Enum.take_random(targeting_strategy.count) |> Enum.map(fn {id, _unit} -> id end) - defp choose_targets_by_strategy(caster, %{count: count, type: "nearest", target_allies: target_allies}, state) do - config_name = if target_allies, do: :ally_proximities, else: :enemy_proximities + defp choose_targets_by_strategy(caster, %{type: "nearest"} = targeting_strategy, state) do + config_name = if targeting_strategy.target_allies, do: :ally_proximities, else: :enemy_proximities state.units |> Enum.map(fn {_id, unit} -> unit end) - |> Enum.filter(fn unit -> unit.team == caster.team == target_allies and unit.id != caster.id end) + |> Enum.filter(fn unit -> unit.team == caster.team == targeting_strategy.target_allies and unit.id != caster.id end) |> find_by_proximity( Application.get_env(:champions, :"slot_#{caster.slot}_proximities")[config_name], - count + targeting_strategy.count ) |> Enum.map(& &1.id) end - defp choose_targets_by_strategy(caster, %{count: count, type: "furthest", target_allies: target_allies}, state) do - config_name = if target_allies, do: :ally_proximities, else: :enemy_proximities + defp choose_targets_by_strategy(caster, %{type: "furthest"} = targeting_strategy, state) do + config_name = if targeting_strategy.target_allies, do: :ally_proximities, else: :enemy_proximities state.units |> Enum.map(fn {_id, unit} -> unit end) - |> Enum.filter(fn unit -> unit.team == caster.team == target_allies and unit.id != caster.id end) + |> Enum.filter(fn unit -> unit.team == caster.team == targeting_strategy.target_allies and unit.id != caster.id end) |> find_by_proximity( Application.get_env(:champions, :"slot_#{caster.slot}_proximities")[config_name] |> Enum.reverse(), - count + targeting_strategy.count ) |> Enum.map(& &1.id) end - defp choose_targets_by_strategy(caster, %{type: "backline", target_allies: target_allies}, state) do + defp choose_targets_by_strategy(caster, %{type: "backline"} = targeting_strategy, state) do target_team = - Enum.filter(state.units, fn {_id, unit} -> unit.team == caster.team == target_allies end) + Enum.filter(state.units, fn {_id, unit} -> unit.team == caster.team == targeting_strategy.target_allies end) take_unit_ids_by_slots(target_team, [3, 4, 5, 6]) end - defp choose_targets_by_strategy(caster, %{type: "frontline", target_allies: target_allies}, state) do + defp choose_targets_by_strategy(caster, %{type: "frontline"} = targeting_strategy, state) do target_team = - Enum.filter(state.units, fn {_id, unit} -> unit.team == caster.team == target_allies end) + Enum.filter(state.units, fn {_id, unit} -> unit.team == caster.team == targeting_strategy.target_allies end) take_unit_ids_by_slots(target_team, [1, 2]) end @@ -705,10 +705,32 @@ defmodule Champions.Battle.Simulator do [caster.id] end - defp choose_targets_by_strategy(caster, %{type: "all", target_allies: target_allies}, state), + defp choose_targets_by_strategy(caster, %{type: %{"lowest" => stat}} = targeting_strategy, state) do + choose_units_by_stat_and_team( + state.units, + stat, + targeting_strategy.count, + caster, + targeting_strategy.target_allies, + :desc + ) + end + + defp choose_targets_by_strategy(caster, %{type: %{"highest" => stat}} = targeting_strategy, state) do + choose_units_by_stat_and_team( + state.units, + stat, + targeting_strategy.count, + caster, + targeting_strategy.target_allies, + :asc + ) + end + + defp choose_targets_by_strategy(caster, %{type: "all"} = targeting_strategy, state), do: state.units - |> Enum.filter(fn {_id, unit} -> unit.team == caster.team == target_allies end) + |> Enum.filter(fn {_id, unit} -> unit.team == caster.team == targeting_strategy.target_allies end) |> Enum.map(fn {id, _unit} -> id end) defp find_by_proximity(units, slots_priorities, amount) do @@ -720,6 +742,16 @@ defmodule Champions.Battle.Simulator do Enum.take(sorted_units, amount) end + defp choose_units_by_stat_and_team(units, stat, count, caster, target_allies, order) do + target_team = + Enum.filter(units, fn {_id, unit} -> unit.team == caster.team == target_allies end) + + Enum.map(target_team, fn {_id, unit} -> unit end) + |> sort_units_by_stat(stat, order) + |> Enum.take(count) + |> Enum.map(fn unit -> unit.id end) + end + defp take_unit_ids_by_slots(units, slots) do slots_units = Enum.filter(units, fn {_id, unit} -> unit.slot in slots end) @@ -1162,20 +1194,29 @@ defmodule Champions.Battle.Simulator do "all", "frontline", "backline", - "self" + "self", + "lowest", + "highest" ] defp create_mechanics_map(%Mechanic{} = mechanic, skill_id, caster_id) do + targeting_strategy_type = mechanic.apply_effects_to.targeting_strategy.type + apply_effects_to = %{ effects: Enum.map(mechanic.apply_effects_to.effects, &create_effect_map(&1, skill_id)), targeting_strategy: %{ # TODO: replace random for the corresponding target type name (CHoM #325) # type: mechanic.apply_effects_to.targeting_strategy.type, type: - if mechanic.apply_effects_to.targeting_strategy.type in @implemented_targeting_strategies do - mechanic.apply_effects_to.targeting_strategy.type - else - "random" + cond do + is_binary(targeting_strategy_type) && targeting_strategy_type in @implemented_targeting_strategies -> + targeting_strategy_type + + hd(Map.keys(targeting_strategy_type)) in @implemented_targeting_strategies -> + targeting_strategy_type + + true -> + "random" end, count: mechanic.apply_effects_to.targeting_strategy.count || 1, target_allies: mechanic.apply_effects_to.targeting_strategy.target_allies || false @@ -1259,6 +1300,34 @@ defmodule Champions.Battle.Simulator do } end + defp sort_units_by_stat(units, stat, order) do + Enum.sort( + units, + fn unit_1, unit_2 -> + unit_1_stat = calculate_unit_stat(unit_1, String.to_atom(stat)) + unit_2_stat = calculate_unit_stat(unit_2, String.to_atom(stat)) + + decide_order(unit_1_stat, unit_2_stat, order) + end + ) + end + + defp decide_order(unit_1_stat, unit_2_stat, :asc) do + cond do + unit_1_stat > unit_2_stat -> true + unit_1_stat == unit_2_stat -> Enum.random([true, false]) + true -> false + end + end + + defp decide_order(unit_1_stat, unit_2_stat, :desc) do + cond do + unit_1_stat > unit_2_stat -> false + unit_1_stat == unit_2_stat -> Enum.random([true, false]) + true -> true + end + end + defp string_to_atom("type"), do: :type defp string_to_atom("duration"), do: :duration defp string_to_atom("period"), do: :period diff --git a/apps/champions/test/battle_test.exs b/apps/champions/test/battle_test.exs index 54a20fcba..39b000f21 100644 --- a/apps/champions/test/battle_test.exs +++ b/apps/champions/test/battle_test.exs @@ -1243,6 +1243,207 @@ defmodule Champions.Test.BattleTest do assert "team_2" == Champions.Battle.Simulator.run_battle([self_damage_unit], [target_dummy], maximum_steps: maximum_steps).result end + + test "Lowest Health" do + # This test pairs a unit with a basic skill that deals damage to the enemy with the lowest health, against two targets. + # The first target has 10 health points and the second one has 9 health points. The enemy with lowest health can kill the opponent in one hit, so if the lowest health targeting strategy fails, the battle will end in a victory for the team_2. + # The second enemy will hit team_1 unit once, for half of its health points, so if it hits twice the battle will end in a victory for the team_2. + + attacker_cooldown = 1 + attacking_character_hp = 20 + attacking_character_base_attack = 10 + highest_enemy_hp = attacking_character_base_attack + lowest_enemy_hp = attacking_character_base_attack - 1 + + # Create a character with a basic skill that will deal 10 damage to all the enemies + basic_skill_params = + TestUtils.build_skill(%{ + name: "DealDamage Lowest HP Enemy", + mechanics: [ + %{ + trigger_delay: 0, + apply_effects_to: + TestUtils.build_apply_effects_to_mechanic(%{ + effects: [ + TestUtils.build_effect(%{ + executions: [ + %{ + type: "DealDamage", + attack_ratio: 1, + energy_recharge: 0 + } + ] + }) + ], + targeting_strategy: %{ + type: %{"lowest" => "health"}, + target_allies: false, + count: 1 + } + }) + } + ], + cooldown: attacker_cooldown * @miliseconds_per_step + }) + + {:ok, character} = + TestUtils.build_character(%{ + name: "Lowest HP Attacking Character", + basic_skill: basic_skill_params, + ultimate_skill: TestUtils.build_skill(%{name: "Lowest target Skill"}), + base_attack: attacking_character_base_attack, + base_health: attacking_character_hp + }) + |> Characters.insert_character() + + {:ok, attacking_unit} = TestUtils.build_unit(%{character_id: character.id}) |> Units.insert_unit() + {:ok, attacking_unit} = Units.get_unit(attacking_unit.id) + + {:ok, enemy_character_lowest_health} = + TestUtils.build_character(%{ + base_health: lowest_enemy_hp, + base_attack: attacking_character_hp, + name: "Lowest HP Target Enemy", + basic_skill: + TestUtils.build_skill(%{ + name: "Lowest HP Target Enemy Basic", + cooldown: 1 + attacker_cooldown * @miliseconds_per_step + }), + ultimate_skill: TestUtils.build_skill(%{name: "Lowest HP Target Enemy Ultimate"}) + }) + |> Characters.insert_character() + + {:ok, enemy_lowest_health} = + TestUtils.build_unit(%{character_id: enemy_character_lowest_health.id}) |> Units.insert_unit() + + {:ok, enemy_lowest_health} = Units.get_unit(enemy_lowest_health.id) + + {:ok, enemy_character_highest_health} = + TestUtils.build_character(%{ + base_health: highest_enemy_hp, + base_attack: trunc(attacking_character_hp / 2), + name: "Highest HP Target Enemy", + basic_skill: + TestUtils.build_skill(%{ + name: "Highest HP Target Enemy Basic", + cooldown: 1 + attacker_cooldown * @miliseconds_per_step + }), + ultimate_skill: TestUtils.build_skill(%{name: "Highest HP Target Enemy Ultimate"}) + }) + |> Characters.insert_character() + + {:ok, enemy_highest_health} = + TestUtils.build_unit(%{character_id: enemy_character_highest_health.id}) |> Units.insert_unit() + + {:ok, enemy_highest_health} = Units.get_unit(enemy_highest_health.id) + + assert "team_1" == + Champions.Battle.Simulator.run_battle( + [attacking_unit], + [enemy_lowest_health, enemy_highest_health], + maximum_steps: 5 + ).result + end + + test "Highest Health" do + # This test pairs a unit with a basic skill that deals damage to the enemy with the highest health, against two targets. + # The first target has 10 health points and the second one has 9 health points. The enemy with highest health can kill the opponent in one hit, so if the highest health targeting strategy fails, the battle will end in a victory for the team_2. + # The second enemy will hit team_1 unit once, for half of its health points, so if it hits twice the battle will end in a victory for the team_2. + + attacker_cooldown = 1 + attacking_character_hp = 20 + attacking_character_base_attack = 10 + highest_enemy_hp = attacking_character_base_attack + lowest_enemy_hp = attacking_character_base_attack - 1 + + # Create a character with a basic skill that will deal 10 damage to all the enemies + basic_skill_params = + TestUtils.build_skill(%{ + name: "DealDamage Highest HP Enemy", + mechanics: [ + %{ + trigger_delay: 0, + apply_effects_to: + TestUtils.build_apply_effects_to_mechanic(%{ + effects: [ + TestUtils.build_effect(%{ + executions: [ + %{ + type: "DealDamage", + attack_ratio: 1, + energy_recharge: 0 + } + ] + }) + ], + targeting_strategy: %{ + type: %{"highest" => "health"}, + target_allies: false + } + }) + } + ], + cooldown: attacker_cooldown * @miliseconds_per_step + }) + + {:ok, character} = + TestUtils.build_character(%{ + name: "Highest HP Attacking Character", + basic_skill: basic_skill_params, + ultimate_skill: TestUtils.build_skill(%{name: "Highest target ultimate Skill"}), + base_attack: attacking_character_base_attack, + base_health: attacking_character_hp + }) + |> Characters.insert_character() + + {:ok, attacking_unit} = TestUtils.build_unit(%{character_id: character.id}) |> Units.insert_unit() + {:ok, attacking_unit} = Units.get_unit(attacking_unit.id) + + {:ok, enemy_character_lowest_health} = + TestUtils.build_character(%{ + base_health: lowest_enemy_hp, + base_attack: trunc(attacking_character_hp / 2), + name: "Lowest HP Enemy", + basic_skill: + TestUtils.build_skill(%{ + name: "Lowest HP Enemy Basic", + cooldown: 1 + attacker_cooldown * @miliseconds_per_step + }), + ultimate_skill: TestUtils.build_skill(%{name: "Lowest HP Enemy Ultimate"}) + }) + |> Characters.insert_character() + + {:ok, enemy_lowest_health} = + TestUtils.build_unit(%{character_id: enemy_character_lowest_health.id}) |> Units.insert_unit() + + {:ok, enemy_lowest_health} = Units.get_unit(enemy_lowest_health.id) + + {:ok, enemy_character_highest_health} = + TestUtils.build_character(%{ + base_health: highest_enemy_hp, + base_attack: attacking_character_hp, + name: "Highest HP Enemy", + basic_skill: + TestUtils.build_skill(%{ + name: "Highest HP Enemy Basic", + cooldown: 1 + attacker_cooldown * @miliseconds_per_step + }), + ultimate_skill: TestUtils.build_skill(%{name: "Highest HP Enemy Ultimate"}) + }) + |> Characters.insert_character() + + {:ok, enemy_highest_health} = + TestUtils.build_unit(%{character_id: enemy_character_highest_health.id}) |> Units.insert_unit() + + {:ok, enemy_highest_health} = Units.get_unit(enemy_highest_health.id) + + assert "team_1" == + Champions.Battle.Simulator.run_battle( + [attacking_unit], + [enemy_lowest_health, enemy_highest_health], + maximum_steps: 5 + ).result + end end describe "Items" do diff --git a/apps/game_backend/lib/game_backend/units/skills/mechanics/targeting_strategies/target_strategy.ex b/apps/game_backend/lib/game_backend/units/skills/mechanics/targeting_strategies/target_strategy.ex index 045686d7e..6424361cd 100644 --- a/apps/game_backend/lib/game_backend/units/skills/mechanics/targeting_strategies/target_strategy.ex +++ b/apps/game_backend/lib/game_backend/units/skills/mechanics/targeting_strategies/target_strategy.ex @@ -24,8 +24,8 @@ defmodule GameBackend.Units.Skills.Mechanics.TargetingStrategies.Type do # Needed for the case in which we decode from the DB (Further explanation in: https://hexdocs.pm/ecto/Ecto.Schema.html#embeds_one/3-encoding-and-decoding) def cast(%{"factions" => factions}), do: {:ok, {"factions", factions}} def cast(%{"classes" => classes}), do: {:ok, {"classes", classes}} - def cast(%{"lowest" => attribute}), do: {:ok, %{lowest: attribute}} - def cast(%{"highest" => attribute}), do: {:ok, %{highest: attribute}} + def cast(%{"lowest" => attribute}), do: {:ok, %{"lowest" => attribute}} + def cast(%{"highest" => attribute}), do: {:ok, %{"highest" => attribute}} def cast(_), do: :error