diff --git a/apps/champions/lib/champions/battle/simulator.ex b/apps/champions/lib/champions/battle/simulator.ex index ce8e5a3bc..45807dc5e 100644 --- a/apps/champions/lib/champions/battle/simulator.ex +++ b/apps/champions/lib/champions/battle/simulator.ex @@ -6,34 +6,51 @@ defmodule Champions.Battle.Simulator do has no cooldown and it's cast whenever a unit reaches 500 energy. Energy is gained whenever the target attacks. The primary skill has a cooldown and it's cast when it's available if the ultimate is not. - Skills possess many effects with their own targets. Effects are composed of `Components`, `Modifiers` and - `Executions` (check module docs for more info on each). + Skills possess many mechanics. The only implemented mechanic right now is `ApplyEffectsTo`, which is composed of many effects + and a targeting strategy. Effects are composed of `Components`, `Modifiers` and `Executions` (check module docs for more info on each). - They have different application types (checked are implemented): + ### ApplyEffectsTo mechanics + + Effects have different application types: [x] Instant - Applied once, irreversible. [x] Permanent - Applied once, is stored in the unit so that it can be reversed (with a dispel, for example) [x] Duration - Applied once and reverted once its duration ends. + [ ] Periodic - Applied every X steps until duration ends. - They also have different targeting strategies: + The different targeting strategies are: [x] Random - [X] Nearest - [X] Furthest - [ ] Frontline - Heroes in slots 1 and 2 - [ ] Backline - Heroes in slots 2 to 4 + [x] Nearest + [x] Furthest + [x] Frontline - Heroes in slots 1 and 2 + [x] Backline - Heroes in slots 2 to 4 + [x] All + [ ] Self [ ] Factions [ ] Classes [ ] Min (STAT) [ ] Max (STAT) - And different ways in which their amount is interpreted: - [x] Additive - [x] Multiplicative - [x] Additive & based on stat - The amount is a % of one of the caster's stats - [ ] Multiplicative & based on stat? + It can also be chosen how many targets are affected by the effect, and if they are allies or enemies. + + + ### Simultaneous Battles Two units can attack the same unit at the same time and over-kill it. This is expected behavior that results - from having the battle be simultaneous. + from having the battle be simultaneous. If this weren't the case, the battle would be turn-based, since a unit + would base its actions on the state of the battle at the end of the previous unit's action. + + ### Speed Stat + Units have a `speed` stat that affects the cooldown of their basic skill. The formula is: + `FINAL_CD = BASE_CD / [1 + MAX(-99, SPEED) / 100];` + For now, speed is only used to calculate the cooldown of newly cast skills, meaning it's not retroactive with + skills already on cooldown. + + ### History + A "history" is built as the battle progresses. This history is used to animate the battle in the client. The history + is a list of maps, each map representing a step in the battle. Each step has a `step_number` and a list of `actions`. + These are all translated into Protobuf messages, together with the initial state of the battle and the result, + and then sent to the client. """ alias Champions.Units alias GameBackend.Units.Skills.Skill @@ -208,7 +225,7 @@ defmodule Champions.Battle.Simulator do |> put_in( [:units, unit.id, :basic_skill, :remaining_cooldown], # We need this + 1 because we're going to reduce the cooldown at the end of the step - unit.basic_skill.base_cooldown + 1 + calculate_cooldown(unit.basic_skill, unit) + 1 ) |> update_in([:units, unit.id, :energy], &(&1 + unit.basic_skill.energy_regen)) @@ -267,6 +284,16 @@ defmodule Champions.Battle.Simulator do {new_state, new_history} end + defp calculate_cooldown(skill, unit) do + speed = calculate_unit_stat(unit, :speed) |> Decimal.from_float() + + divisor = Decimal.div(Decimal.max(-99, speed), 100) |> Decimal.add(1) + + Decimal.div(skill.base_cooldown, divisor) + |> Decimal.round() + |> Decimal.to_integer() + end + # Reduces modifier timers and removes expired ones. # Called when processing a step for a unit. defp reduce_modifier_timers(modifiers, unit, history) do @@ -966,6 +993,7 @@ defmodule Champions.Battle.Simulator do health: Units.get_health(unit), attack: Units.get_attack(unit), defense: Units.get_defense(unit), + speed: Units.get_speed(unit), energy: 0, modifiers: %{ additives: [], @@ -1104,6 +1132,7 @@ defmodule Champions.Battle.Simulator do defp string_to_atom("duration"), do: :duration defp string_to_atom("period"), do: :period defp string_to_atom("instant"), do: :instant + defp string_to_atom("permanent"), do: :permanent defp string_to_atom("ATTACK"), do: :ATTACK defp string_to_atom("DEFENSE"), do: :DEFENSE diff --git a/apps/champions/lib/champions/units.ex b/apps/champions/lib/champions/units.ex index de2c6486c..6274a5610 100644 --- a/apps/champions/lib/champions/units.ex +++ b/apps/champions/lib/champions/units.ex @@ -367,7 +367,7 @@ defmodule Champions.Units do @doc """ Get a unit's health stat for battle, including modifiers from items. - Character and ItemTemplate must be preloaded. + Character, Items and ItemTemplates must be preloaded. ## Examples @@ -380,7 +380,7 @@ defmodule Champions.Units do @doc """ Get a unit's attack stat for battle, including modifiers from items. - Character and ItemTemplate must be preloaded. + Character, Items and ItemTemplates must be preloaded. ## Examples @@ -393,7 +393,7 @@ defmodule Champions.Units do @doc """ Get a unit's defense stat for battle, including modifiers from items. - Character and ItemTemplate must be preloaded. + Character, Items and ItemTemplates must be preloaded. ## Examples @@ -403,6 +403,20 @@ defmodule Champions.Units do """ def get_defense(unit), do: calculate_stat(unit.character.base_defense, unit, "defense") + @doc """ + Get a unit's speed stat for battle, including modifiers from items. + Unlike other stats, speed is not affected by the unit's level, tier or rank. + + Items and Templates must be preloaded. + + ## Examples + + iex> {:ok, unit} = Champions.Units.get_unit(unit_id) + iex> Champions.Units.get_speed(unit) + 100 + """ + def get_speed(unit), do: factor_items(Decimal.new(0), unit.items, "speed") + defp calculate_stat(base_stat, unit, stat_name), do: base_stat @@ -459,17 +473,13 @@ defmodule Champions.Units do end defp get_additive_and_multiplicative_modifiers(items, attribute) do - item_modifiers = - Enum.flat_map(items, & &1.template.modifiers) + item_modifiers = Enum.flat_map(items, & &1.template.modifiers) - attribute_modifiers = - Enum.filter(item_modifiers, &(&1.attribute == attribute)) + attribute_modifiers = Enum.filter(item_modifiers, &(&1.attribute == attribute)) - additive_modifiers = - Enum.filter(attribute_modifiers, &(&1.operation == "Add")) + additive_modifiers = Enum.filter(attribute_modifiers, &(&1.operation == "Add")) - multiplicative_modifiers = - Enum.filter(attribute_modifiers, &(&1.operation == "Multiply")) + multiplicative_modifiers = Enum.filter(attribute_modifiers, &(&1.operation == "Multiply")) {additive_modifiers, multiplicative_modifiers} end diff --git a/apps/champions/test/battle_test.exs b/apps/champions/test/battle_test.exs index 009ce627d..80530c86a 100644 --- a/apps/champions/test/battle_test.exs +++ b/apps/champions/test/battle_test.exs @@ -1067,4 +1067,227 @@ defmodule Champions.Test.BattleTest do Champions.Battle.Simulator.run_battle([unit], [target_dummy], maximum_steps: maximum_steps).result end end + + describe "Speed" do + test "Positive speed makes cooldowns shorter", %{target_dummy: target_dummy} do + # We will create a team with two units (A speed buffing unit and a damaging unit) against a target dummy + # Damaging unit will deal 5 damage per hit, and the target dummy has 10 health points. + # Cooldown for the damaging unit is 4 steps, so it can hit only once in an 8 step battle. + # Speeding unit will buff its speed up to a point where cooldowns are halved, so it gets to hit twice and team 1 wins. + + # Battle will go like this (S = speed buff, D = damage) + # _ _ S _ D S _ D + maximum_steps = 8 + speed_cooldown = 2 + damage_cooldown = 4 + + speed_params = + TestUtils.build_skill(%{ + name: "SpeedBuff-SpeedBuffSkill", + mechanics: [ + %{ + trigger_delay: 0, + apply_effects_to: + TestUtils.build_apply_effects_to_mechanic(%{ + effects: [ + TestUtils.build_effect(%{ + type: %{ + "type" => "permanent" + }, + modifiers: [ + %{ + attribute: "speed", + operation: "Add", + magnitude: 100 + } + ] + }) + ], + targeting_strategy: %{ + count: 1, + # Nearest so that the speeder doesn't target himself + type: "nearest", + target_allies: true + } + }) + } + ], + cooldown: speed_cooldown * @miliseconds_per_step + }) + + {:ok, speed_character} = + TestUtils.build_character(%{ + name: "SpeedBuff-SpeedBuffCharacter", + basic_skill: speed_params, + ultimate_skill: TestUtils.build_skill(%{name: "SpeedBuff-SpeedBuffEmptySkill"}) + }) + |> Characters.insert_character() + + {:ok, speeder} = + TestUtils.build_unit(%{character_id: speed_character.id, slot: 1}) |> Units.insert_unit() + + {:ok, speeder} = Units.get_unit(speeder.id) + + damage_params = + TestUtils.build_skill(%{ + name: "SpeedBuff-DamageSkill", + 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: %{ + count: 1, + type: "nearest", + target_allies: false + } + }) + } + ], + cooldown: damage_cooldown * @miliseconds_per_step + }) + + {:ok, damager_character} = + TestUtils.build_character(%{ + name: "SpeedBuff-DamageCharacter", + basic_skill: damage_params, + ultimate_skill: TestUtils.build_skill(%{name: "SpeedBuff-DamageEmptySkill"}), + base_attack: 5 + }) + |> Characters.insert_character() + + {:ok, damager} = + TestUtils.build_unit(%{character_id: damager_character.id, slot: 2}) |> Units.insert_unit() + + {:ok, damager} = Units.get_unit(damager.id) + + assert "team_1" == + Champions.Battle.Simulator.run_battle([speeder, damager], [target_dummy], maximum_steps: maximum_steps).result + end + + test "Negative speed makes cooldowns longer" do + # We will create a team with a speed debuffing unit and a team with a damaging unit. + # Damaging unit will deal 5 damage per hit, and the speed buffing unit has 10 health points. + # Cooldown for the damaging unit is 2 steps, so it can hit twice in a 7 step battle. + # Speeding unit will debuff the damaging unit's speed down to a point where cooldowns are doubled, so it gets to hit only once + # so we get a timeout. + + # Battle will go like this (S = speed debuff, D = damage) + # _ S D S _ S _ + maximum_steps = 7 + speed_cooldown = 1 + damage_cooldown = 2 + + speed_params = + TestUtils.build_skill(%{ + name: "SpeedDebuff-SpeedDebuffSkill", + mechanics: [ + %{ + trigger_delay: 0, + apply_effects_to: + TestUtils.build_apply_effects_to_mechanic(%{ + effects: [ + TestUtils.build_effect(%{ + type: %{ + "type" => "duration", + "duration" => -1, + "period" => 0 + }, + modifiers: [ + %{ + attribute: "speed", + operation: "Add", + magnitude: -50 + } + ] + }) + ], + targeting_strategy: %{ + count: 1, + type: "random", + target_allies: false + } + }) + } + ], + cooldown: speed_cooldown * @miliseconds_per_step + }) + + {:ok, speed_character} = + TestUtils.build_character(%{ + name: "SpeedDebuff-SpeedDebuffCharacter", + basic_skill: speed_params, + ultimate_skill: TestUtils.build_skill(%{name: "SpeedDebuff-DebuffEmptySkill"}), + base_health: 10 + }) + |> Characters.insert_character() + + {:ok, speeder} = + TestUtils.build_unit(%{character_id: speed_character.id, slot: 1}) |> Units.insert_unit() + + {:ok, speeder} = Units.get_unit(speeder.id) + + damage_params = + TestUtils.build_skill(%{ + name: "SpeedDebuff-DamageSkill", + 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: %{ + count: 1, + type: "nearest", + target_allies: false + } + }) + } + ], + cooldown: damage_cooldown * @miliseconds_per_step + }) + + {:ok, damager_character} = + TestUtils.build_character(%{ + name: "SpeedDebuff-DamageCharacter", + basic_skill: damage_params, + ultimate_skill: TestUtils.build_skill(%{name: "SpeedDebuff-DamageEmptySkill"}), + base_attack: 5 + }) + |> Characters.insert_character() + + {:ok, damager} = + TestUtils.build_unit(%{character_id: damager_character.id, slot: 2}) |> Units.insert_unit() + + {:ok, damager} = Units.get_unit(damager.id) + + assert "timeout" == + Champions.Battle.Simulator.run_battle([speeder], [damager], maximum_steps: maximum_steps).result + + # If battle lasted 1 step longer, speeder dies + assert "team_2" == + Champions.Battle.Simulator.run_battle([speeder], [damager], maximum_steps: maximum_steps + 1).result + end + end end