class AttackAction

typed: true

Attributes

advantage_mod[R]
as_reaction[RW]
npc_action[RW]
second_hand[RW]
target[RW]
thrown[RW]
using[RW]

Public Class Methods

apply!(battle, item) click to toggle source

@param battle [Natural20::Battle]

# File lib/natural_20/actions/attack_action.rb, line 74
def self.apply!(battle, item)
  if item[:flavor]
    Natural20::EventManager.received_event({ event: :flavor, source: item[:source], target: item[:target],
                                             text: item[:flavor] })
  end
  case (item[:type])
  when :prone
    item[:source].prone!
  when :damage
    damage_event(item, battle)
    consume_resource(battle, item)
  when :miss
    consume_resource(battle, item)
    Natural20::EventManager.received_event({ attack_roll: item[:attack_roll],
                                             attack_name: item[:attack_name],
                                             advantage_mod: item[:advantage_mod],
                                             as_reaction: !!item[:as_reaction],
                                             adv_info: item[:adv_info],
                                             source: item[:source], target: item[:target], event: :miss })
  end
end
build(session, source) click to toggle source
# File lib/natural_20/actions/attack_action.rb, line 68
def self.build(session, source)
  action = AttackAction.new(session, source, :attack)
  action.build_map
end
can?(entity, battle, options = {}) click to toggle source

@param entity [Natural20::Entity] @param battle [Natural20::Battle] @return [Boolean]

# File lib/natural_20/actions/attack_action.rb, line 14
def self.can?(entity, battle, options = {})
  return entity.total_reactions(battle).positive? if battle && options[:opportunity_attack]

  battle.nil? || entity.total_actions(battle).positive? || entity.multiattack?(
    battle, options[:npc_action]
  )
end
consume_resource(battle, item) click to toggle source

@param battle [Natural20::Battle] @param item [Hash]

# File lib/natural_20/actions/attack_action.rb, line 98
def self.consume_resource(battle, item)
  # handle ammo
  item[:source].deduct_item(item[:ammo], 1) if item[:ammo]

  # hanle thrown items
  if item[:thrown]
    if item[:source].item_count(item[:weapon]).positive?
      item[:source].deduct_item(item[:weapon], 1)
    else
      item[:source].unequip(item[:weapon], transfer_inventory: false)
    end

    if item[:type] == :damage
      item[:target].add_item(item[:weapon])
    else
      ground_pos = item[:battle].map.entity_or_object_pos(item[:target])
      ground_object = item[:battle].map.objects_at(*ground_pos).detect { |o| o.is_a?(ItemLibrary::Ground) }
      ground_object&.add_item(item[:weapon])
    end
  end

  if item[:as_reaction]
    battle.consume(item[:source], :reaction)
  elsif item[:second_hand]
    battle.consume(item[:source], :bonus_action)
  else
    battle.consume(item[:source], :action)
  end

  item[:source].break_stealth!(battle)

  # handle two-weapon fighting
  weapon = battle.session.load_weapon(item[:weapon]) if item[:weapon]

  if weapon && weapon[:properties]&.include?('light') && !battle.two_weapon_attack?(item[:source]) && !item[:second_hand]
    battle.entity_state_for(item[:source])[:two_weapon] = item[:weapon]
  elsif battle.entity_state_for(item[:source])
    battle.entity_state_for(item[:source])[:two_weapon] = nil
  end

  # handle multiattacks
  if battle.entity_state_for(item[:source])
    battle.entity_state_for(item[:source])[:multiattack]&.each do |_group, attacks|
      if attacks.include?(item[:attack_name])
        attacks.delete(item[:attack_name])
        item[:source].clear_multiattack!(battle) if attacks.empty?
      end
    end
  end

  # dismiss help actions
  battle.dismiss_help_for(item[:target])
end

Public Instance Methods

build_map() click to toggle source
# File lib/natural_20/actions/attack_action.rb, line 40
def build_map
  OpenStruct.new({
                   action: self,
                   param: [
                     {
                       type: :select_target,
                       num: 1,
                       weapon: using
                     }
                   ],
                   next: lambda { |target|
                     self.target = target
                     OpenStruct.new({
                                      param: [
                                        { type: :select_weapon }
                                      ],
                                      next: lambda { |weapon|
                                              self.using = weapon
                                              OpenStruct.new({
                                                               param: nil,
                                                               next: -> { self }
                                                             })
                                            }
                                    })
                   }
                 })
end
label() click to toggle source
# File lib/natural_20/actions/attack_action.rb, line 26
def label
  if @npc_action
    t('action.npc_action', name: @action_type.to_s.humanize, action_name: npc_action[:name])
  else
    weapon = session.load_weapon(@opts[:using] || @using)
    attack_mod = @source.attack_roll_mod(weapon)

    i18n_token = thrown ? 'action.attack_action_throw' : 'action.attack_action'

    t(i18n_token, name: @action_type.to_s.humanize, weapon_name: weapon[:name], mod: attack_mod >= 0 ? "+#{attack_mod}" : attack_mod,
                  dmg: damage_modifier(@source, weapon, second_hand: second_hand))
  end
end
resolve(_session, map, opts = {}) click to toggle source

Build the attack roll information @param session [Natural20::Session] @param map [Natural20::BattleMap] @option opts battle [Natural20::Battle] @option opts target [Natural20::Entity]

# File lib/natural_20/actions/attack_action.rb, line 165
def resolve(_session, map, opts = {})
  @result.clear
  target = opts[:target] || @target
  raise 'target is a required option for :attack' if target.nil?

  npc_action = opts[:npc_action] || @npc_action
  battle = opts[:battle]
  using = opts[:using] || @using
  raise 'using or npc_action is a required option for :attack' if using.nil? && npc_action.nil?

  attack_name = nil
  damage_roll = nil
  sneak_attack_roll = nil
  ammo_type = nil

  npc_action = @source.npc_actions.detect { |a| a[:name].downcase == using.downcase } if @source.npc? && using

  if @source.npc?

    if npc_action.nil?
      npc_action = @source.properties[actions].detect do |action|
        action[:name].downcase == using.to_s.downcase
      end
    end
    weapon = npc_action
    attack_name = npc_action[:name]
    attack_mod = npc_action[:attack]
    damage_roll = npc_action[:damage_die]
    ammo_type = npc_action[:ammo]
  else
    weapon = session.load_weapon(using.to_sym)
    attack_name = weapon[:name]
    ammo_type = weapon[:ammo]
    attack_mod = @source.attack_roll_mod(weapon)
    damage_roll = damage_modifier(@source, weapon, second_hand: second_hand)
  end

  # DnD 5e advantage/disadvantage checks
  @advantage_mod, adv_info = target_advantage_condition(battle, @source, target, weapon)

  # determine eligibility for the 'Protection' fighting style
  evaluate_feature_protection(battle, map, target, adv_info) if map

  # perform the dice rolls
  attack_roll = Natural20::DieRoll.roll("1d20+#{attack_mod}", disadvantage: with_disadvantage?,
                                                              advantage: with_advantage?,
                                                              description: t('dice_roll.attack'), entity: @source, battle: battle)

  # handle the lucky feat
  attack_roll = attack_roll.reroll(lucky: true) if @source.class_feature?('lucky') && attack_roll.nat_1?
  target_ac, _cover_ac = effective_ac(battle, target)
  after_attack_roll_hook(battle, target, source, attack_roll, target_ac)

  if @source.class_feature?('sneak_attack') && (weapon[:properties]&.include?('finesse') || weapon[:type] == 'ranged_attack') && (with_advantage? || battle.enemy_in_melee_range?(
    target, [@source]
  ))
    sneak_attack_roll = Natural20::DieRoll.roll(@source.sneak_attack_level, crit: attack_roll.nat_20?,
                                                                            description: t('dice_roll.sneak_attack'), entity: @source, battle: battle)
  end

  damage = Natural20::DieRoll.roll(damage_roll, crit: attack_roll.nat_20?, description: t('dice_roll.damage'),
                                                entity: @source, battle: battle)

  if @source.class_feature?('great_weapon_fighting') && (weapon[:properties]&.include?('two_handed') || (weapon[:properties]&.include?('versatile') && entity.used_hand_slots <= 1.0))
    damage.rolls.map do |roll|
      if [1, 2].include?(roll)
        r = Natural20::DieRoll.roll("1d#{damage.die_sides}", description: t('dice_roll.great_weapon_fighting_reroll'),
                                                             entity: @source, battle: battle)
        Natural20::EventManager.received_event({ roll: r, prev_roll: roll,
                                                 source: item[:source], event: :great_weapon_fighting_roll })
        r.result
      else
        roll
      end
    end
  end

  # apply weapon bonus attacks
  damage = check_weapon_bonuses(battle, weapon, damage, attack_roll)

  cover_ac_adjustments = 0
  hit = if attack_roll.nat_20?
          true
        elsif attack_roll.nat_1?
          false
        else
          target_ac, cover_ac_adjustments = effective_ac(battle, target)
          attack_roll.result >= target_ac
        end

  if hit
    @result << {
      source: @source,
      target: target,
      type: :damage,
      thrown: thrown,
      weapon: using,
      battle: battle,
      advantage_mod: @advantage_mod,
      damage_roll: damage_roll,
      attack_name: attack_name,
      attack_roll: attack_roll,
      sneak_attack: sneak_attack_roll,
      target_ac: target.armor_class,
      cover_ac: cover_ac_adjustments,
      adv_info: adv_info,
      hit?: hit,
      damage_type: weapon[:damage_type],
      damage: damage,
      ammo: ammo_type,
      as_reaction: !!as_reaction,
      second_hand: second_hand,
      npc_action: npc_action
    }
    unless weapon[:on_hit].blank?
      weapon[:on_hit].each do |effect|
        next if effect[:if] && !@source.eval_if(effect[:if], weapon: weapon, target: target)

        if effect[:save_dc]
          save_type, dc = effect[:save_dc].split(':')
          raise 'invalid values: save_dc should be of the form <save>:<dc>' if save_type.blank? || dc.blank?
          raise 'invalid save type' unless Natural20::Entity::ATTRIBUTE_TYPES.include?(save_type)

          save_roll = target.saving_throw!(save_type, battle: battle)
          if save_roll.result >= dc.to_i
            if effect[:success]
              @result << target.apply_effect(effect[:success], battle: battle,
                                                               flavor: effect[:flavor_success])
            end
          elsif effect[:fail]
            @result << target.apply_effect(effect[:fail], battle: battle, flavor: effect[:flavor_fail])
          end
        else
          target.apply_effect(effect[:effect])
        end
      end
    end
  else
    @result << {
      attack_name: attack_name,
      source: @source,
      target: target,
      weapon: using,
      battle: battle,
      thrown: thrown,
      type: :miss,
      advantage_mod: @advantage_mod,
      adv_info: adv_info,
      second_hand: second_hand,
      damage_roll: damage_roll,
      attack_roll: attack_roll,
      as_reaction: !!as_reaction,
      target_ac: target.armor_class,
      cover_ac: cover_ac_adjustments,
      ammo: ammo_type,
      npc_action: npc_action
    }
  end

  self
end
to_s() click to toggle source
# File lib/natural_20/actions/attack_action.rb, line 22
def to_s
  @action_type.to_s.humanize
end
with_advantage?() click to toggle source
# File lib/natural_20/actions/attack_action.rb, line 152
def with_advantage?
  @advantage_mod.positive?
end
with_disadvantage?() click to toggle source
# File lib/natural_20/actions/attack_action.rb, line 156
def with_disadvantage?
  @advantage_mod.negative?
end

Protected Instance Methods

check_weapon_bonuses(battle, weapon, damage_roll, attack_roll) click to toggle source
# File lib/natural_20/actions/attack_action.rb, line 355
def check_weapon_bonuses(battle, weapon, damage_roll, attack_roll)
  if weapon.dig(:bonus, :additional, :restriction) == 'nat20_attack' && attack_roll.nat_20?
    damage_roll += Natural20::DieRoll.roll(weapon.dig(:bonus, :additional, :die),
                                           description: t('dice_roll.special_weapon_damage'), entity: @source, battle: battle)
  end

  damage_roll
end
evaluate_feature_protection(battle, map, target, adv_info) click to toggle source

determine eligibility for the 'Protection' fighting style

# File lib/natural_20/actions/attack_action.rb, line 330
def evaluate_feature_protection(battle, map, target, adv_info)
  melee_sqaures = target.melee_squares(map, adjacent_only: true)
  melee_sqaures.each do |pos|
    entity = map.entity_at(*pos)
    next if entity == @source
    next if entity == target
    next unless entity

    next unless entity.class_feature?('protection') && entity.shield_equipped? && entity.has_reaction?(battle)

    controller = battle.controller_for(entity)
    if controller.respond_to?(:reaction) && !controller.reaction(:feature_protection, target: target,
                                                                                      source: entity, attacker: @source)
      next
    end

    Natural20::EventManager.received_event(event: :feature_protection, target: target, source: entity,
                                           attacker: @source)
    _advantage, disadvantage = adv_info
    disadvantage << :protection
    @advantage_mod = -1
    battle.consume(entity, :reaction)
  end
end