class Natural20::Battle

Attributes

battle_log[R]
combat_order[RW]
current_party[RW]
entities[R]
in_combat[R]
map[R]
round[RW]
session[R]
started[R]

Public Class Methods

new(session, map, standard_controller = nil) click to toggle source

Create an instance of a battle @param session [Natural20::Session] @param map [Natural20::BattleMap] @param standard_controller [AiController::Standard]

# File lib/natural_20/battle.rb, line 11
def initialize(session, map, standard_controller = nil)
  @session = session
  @entities = {}
  @groups = {}
  @battle_field_events = {}
  @battle_log = []
  @combat_order = []
  @late_comers = []
  @current_turn_index = 0
  @round = 0
  @map = map
  @in_combat = false
  @standard_controller = standard_controller

  @opposing_groups = {
    a: [:b],
    b: [:a]
  }

  standard_controller&.register_battle_listeners(self)
end

Public Instance Methods

action(source, action_type, opts = {}) click to toggle source
# File lib/natural_20/battle.rb, line 185
def action(source, action_type, opts = {})
  action = source.available_actions(@session, self).detect { |act| act.action_type == action_type }
  opts[:battle] = self
  return action.resolve(@session, @map, opts) if action

  nil
end
action!(action) click to toggle source
# File lib/natural_20/battle.rb, line 193
def action!(action)
  opts = {
    battle: self
  }
  action.resolve(@session, @map, opts)
end
active_perception_for(entity) click to toggle source
# File lib/natural_20/battle.rb, line 220
def active_perception_for(entity)
  @entities[entity][:active_perception] || 0
end
add(entity, group, controller: nil, position: nil, token: nil) click to toggle source

Adds an entity to the battle @param entity [Natural20::Entity] The entity to add to the battle @param group [Symbol] A symbol denoting which opposing group this entity belongs to @param controller [AiController::Standard] Ai controller to use @param position [Array, Symbol] Starting location in the map can be a position or a spawn point @param token [String, Symbol] The token to use

# File lib/natural_20/battle.rb, line 71
def add(entity, group, controller: nil, position: nil, token: nil)
  return if @entities[entity]

  raise 'entity cannot be nil' if entity.nil?

  state = {
    group: group,
    action: 0,
    bonus_action: 0,
    reaction: 0,
    movement: 0,
    stealth: 0,
    statuses: Set.new,
    active_perception: 0,
    active_perception_disadvantage: 0,
    free_object_interaction: 0,
    target_effect: {},
    two_weapon: nil,
    controller: controller || @standard_controller
  }

  @entities[entity] = state

  battle_defaults = entity.try(:battle_defaults)
  if battle_defaults
    battle_defaults[:statuses].each { |s| state[:statuses].add(s.to_sym) }
    unless state[:stealth].blank?
      state[:stealth] =
        DieRoll.roll(battle_defaults[:stealth], description: t('dice_roll.stealth'), entity: entity,
                                                battle: self).result
    end
  end

  @groups[group] ||= Set.new
  @groups[group].add(entity)

  # battle already ongoing...
  if started
    @late_comers << entity
    @entities[entity][:initiative] = entity.initiative!(self)
  end

  return if position.nil?
  return if @map.nil?

  if position.is_a?(Array)
    @map.place(*position, entity, token,
               self)
  else
    @map.place_at_spawn_point(position, entity, token)
  end
end
add_battlefield_event_listener(event, object, handler) click to toggle source
# File lib/natural_20/battle.rb, line 52
def add_battlefield_event_listener(event, object, handler)
  @battle_field_events[event.to_sym] ||= []
  @battle_field_events[event.to_sym] << [object, handler]
end
allies?(entity1, entity2) click to toggle source

Determines if two entities are allies of each other @param entity1 [Natural20::Entity] @param entity2 [Natural20::Entity] @return [Boolean]

# File lib/natural_20/battle.rb, line 319
def allies?(entity1, entity2)
  source_state1 = entity_state_for(entity1)
  source_state2 = entity_state_for(entity2)
  return false if source_state1.nil? || source_state2.nil?

  source_group1 = source_state1[:group]
  source_group2 = source_state2[:group]

  source_group1 == source_group2
end
ally_within_enemey_melee_range?(source, target, exclude = [], source_pos: nil) click to toggle source

Determines if there is a conscious ally within melee range of target @param source [Natural20::Entity] @return [Boolean]

# File lib/natural_20/battle.rb, line 448
def ally_within_enemey_melee_range?(source, target, exclude = [], source_pos: nil)
  objects_around_me = map.look(target)

  objects_around_me.detect do |object, _|
    next if exclude.include?(object)
    next if object == source

    state = entity_state_for(object)
    next unless state
    next unless object.conscious?

    return true if allies?(source, object) && (map.distance(target,
                                                            object, entity_1_pos: source_pos) <= (object.melee_distance / map.feet_per_grid))
  end

  false
end
battle_ends?() click to toggle source
# File lib/natural_20/battle.rb, line 478
def battle_ends?
  groups_present = @entities.keys.reject do |a|
                     a.dead? || a.unconscious?
                   end.map { |e| @entities[e][:group] }.uniq
  groups_present.each do |g|
    groups_present.each do |h|
      next if g == h
      raise "warning unknown group #{g}" unless @opposing_groups[g]
      return false if @opposing_groups[g].include?(h)
    end
  end
  true
end
can_see?(entity1, entity2, active_perception: 0, entity_1_pos: nil, entity_2_pos: nil) click to toggle source

Determines if an entity can see someone in battle @param entity1 [Natural20::Entity] observer @param entity2 [Natural20::Entity] entity being observed @return [Boolean]

# File lib/natural_20/battle.rb, line 275
def can_see?(entity1, entity2, active_perception: 0, entity_1_pos: nil, entity_2_pos: nil)
  return true if entity1 == entity2
  return false unless @map.can_see?(entity1, entity2, entity_1_pos: entity_1_pos, entity_2_pos: entity_2_pos)
  return true unless entity2.hiding?(self)

  cover_value = @map.cover_calculation(@map, entity1, entity2, entity_1_pos: entity_1_pos,
                                                               naturally_stealthy: entity2.class_feature?('naturally_stealthy'))

  if cover_value.positive?
    entity_2_state = entity_state_for(entity2)
    return false if entity_2_state[:stealth] > [active_perception, entity1.passive_perception].max
  end

  true
end
check_combat() click to toggle source
# File lib/natural_20/battle.rb, line 374
def check_combat
  if !@started && !battle_ends?
    start
    Natural20::EventManager.received_event(source: self, event: :start_of_combat, target: current_turn,
                                           combat_order: @combat_order.map { |e| [e, @entities[e][:initiative]] })
    return true
  end
  false
end
combat?() click to toggle source
# File lib/natural_20/battle.rb, line 470
def combat?
  ongoing?
end
commit(action) click to toggle source
# File lib/natural_20/battle.rb, line 504
def commit(action)
  return if action.nil?

  # check_action_serialization(action)
  action.result.each do |item|
    Natural20::Action.descendants.each do |klass|
      klass.apply!(self, item)
    end
  end

  case action.action_type
  when :move
    trigger_event!(:movement, action.source, move_path: action.move_path)
  when :interact
    trigger_event!(:interact, action)
  end
  @battle_log << action
end
compute_max_weapon_range(action, range = nil) click to toggle source
# File lib/natural_20/battle.rb, line 200
def compute_max_weapon_range(action, range = nil)
  case action.action_type
  when :help
    5
  when :attack
    if action.npc_action
      action.npc_action[:range_max].presence || action.npc_action[:range]
    elsif action.using
      weapon = session.load_weapon(action.using)
      if action.thrown
        weapon.dig(:thrown, :range_max) || weapon.dig(:thrown, :range) || weapon[:range]
      else
        weapon[:range_max].presence || weapon[:range]
      end
    end
  else
    range
  end
end
consume(entity, resource, qty = 1) click to toggle source

Consumes an action resource @param entity [Natural20::Entity] @param resouce [Symbol]

# File lib/natural_20/battle.rb, line 533
def consume(entity, resource, qty = 1)
  raise 'invalid resource' unless %i[action reaction bonus_action movement].include?(resource.to_sym)
  return unless entity_state_for(entity)

  entity_state_for(entity)[resource.to_sym] = [0, entity_state_for(entity)[resource.to_sym] - qty].max
end
consume!(entity, resource, qty = 1) click to toggle source

consume action resource and return if something changed

# File lib/natural_20/battle.rb, line 340
def consume!(entity, resource, qty = 1)
  current_qty = entity_state_for(entity)[resource.to_sym]
  new_qty = [0, current_qty - qty].max
  entity_state_for(entity)[resource.to_sym] = new_qty

  current_qty != new_qty
end
controller_for(entity) click to toggle source
# File lib/natural_20/battle.rb, line 132
def controller_for(entity)
  return nil unless @entities.key? entity

  @entities[entity][:controller]
end
current_turn() click to toggle source

@return [Natural20::Entity]

# File lib/natural_20/battle.rb, line 370
def current_turn
  @combat_order[@current_turn_index]
end
dismiss_help_actions_for(source) click to toggle source
# File lib/natural_20/battle.rb, line 167
def dismiss_help_actions_for(source)
  @entities.each do |_k, entity|
    entity[:target_effect]&.delete(source) if %i[help help_ability_check].include?(entity[:target_effect][source])
  end
end
dismiss_help_for(target) click to toggle source
# File lib/natural_20/battle.rb, line 179
def dismiss_help_for(target)
  return unless @entities[target]

  @entities[target][:target_effect].delete_if { |_k, v| v == :help }
end
enemy_in_melee_range?(source, exclude = [], source_pos: nil) click to toggle source

Determines if there is a conscious enemey within melee range @param source [Natural20::Entity] @return [Boolean]

# File lib/natural_20/battle.rb, line 428
def enemy_in_melee_range?(source, exclude = [], source_pos: nil)
  objects_around_me = map.look(source)

  objects_around_me.detect do |object, _|
    next if exclude.include?(object)

    state = entity_state_for(object)
    next unless state
    next unless object.conscious?

    return true if opposing?(source, object) && (map.distance(source,
                                                              object, entity_1_pos: source_pos) <= (object.melee_distance / map.feet_per_grid))
  end

  false
end
entity_group_for(entity) click to toggle source
# File lib/natural_20/battle.rb, line 161
def entity_group_for(entity)
  return :none unless @entities[entity]

  @entities[entity][:group]
end
entity_state_for(entity) click to toggle source

@param entity [Natural20::Entity] @return [Hash]

# File lib/natural_20/battle.rb, line 157
def entity_state_for(entity)
  @entities[entity]
end
first_hand_weapon(entity) click to toggle source
# File lib/natural_20/battle.rb, line 61
def first_hand_weapon(entity)
  entity_state_for(entity)[:two_weapon]
end
has_controller_for?(entity) click to toggle source

Checks if this entity is controlled by AI or Person @param entity [Natural20::Entity] @return [Boolean]

# File lib/natural_20/battle.rb, line 333
def has_controller_for?(entity)
  raise 'unknown entity in battle' unless @entities.key?(entity)

  @entities[entity][:controller] != :manual
end
help_with?(target) click to toggle source
# File lib/natural_20/battle.rb, line 173
def help_with?(target)
  return @entities[target][:target_effect].values.include?(:help) if @entities[target]

  false
end
in_battle?(entity) click to toggle source
# File lib/natural_20/battle.rb, line 124
def in_battle?(entity)
  @entities.key?(entity)
end
move_for(entity) click to toggle source
# File lib/natural_20/battle.rb, line 128
def move_for(entity)
  @entities[entity][:controller].move_for(entity, self)
end
ongoing?() click to toggle source
# File lib/natural_20/battle.rb, line 466
def ongoing?
  @started
end
opponents_of?(entity) click to toggle source

Retruns opponents of entity @param entity [Natural20::Entity] target entity @return [Array<Natrual20::Entity>]

# File lib/natural_20/battle.rb, line 294
def opponents_of?(entity)
  (@entities.keys + @late_comers).reject(&:dead?).select do |k|
    opposing?(k, entity)
  end
end
opposing?(entity1, entity2) click to toggle source

Determines if two entities are opponents of each other @param entity1 [Natural20::Entity] @param entity2 [Natural20::Entity] @return [Boolean]

# File lib/natural_20/battle.rb, line 304
def opposing?(entity1, entity2)
  source_state1 = entity_state_for(entity1)
  source_state2 = entity_state_for(entity2)
  return false if source_state1.nil? || source_state2.nil?

  source_group1 = source_state1[:group]
  source_group2 = source_state2[:group]

  @opposing_groups[source_group1]&.include?(source_group2)
end
register_players(party, controller) click to toggle source

Registers a player party @param party [Array<Natural20::Entity>] Initial Player character party @param controller [Natural20::Controller] An object to be used as callback controller

# File lib/natural_20/battle.rb, line 36
def register_players(party, controller)
  return if party.blank?

  party.each_with_index do |pc, index|
    add(pc, :a, position: "spawn_point_#{index + 1}", controller: controller)
  end
  @current_party = party
  @combat_order = party
end
roll_for(entity, die_type, number_of_times, description, advantage: false, disadvantage: false) click to toggle source
# File lib/natural_20/battle.rb, line 138
def roll_for(entity, die_type, number_of_times, description, advantage: false, disadvantage: false)
  controller = if @entities[entity] && @entities[entity][:controller]
                 @entities[entity][:controller]
               else
                 @standard_controller
               end
  rolls = controller.try(:roll_for, entity, die_type, number_of_times, description,
                         advantage: advantage, disadvantage: disadvantage)
  return rolls if rolls

  if advantage || disadvantage
    number_of_times.times.map { [(1..die_type).to_a.sample, (1..die_type).to_a.sample] }
  else
    number_of_times.times.map { (1..die_type).to_a.sample }
  end
end
start(combat_order = nil) click to toggle source

@param combat_order [Array] If specified will use this array as the initiative order

# File lib/natural_20/battle.rb, line 349
def start(combat_order = nil)
  if combat_order
    @combat_order = combat_order
    return
  end

  # roll for initiative
  @combat_order = @entities.map do |entity, v|
    next if entity.dead?

    v[:initiative] = entity.initiative!(self)

    entity
  end.compact

  @started = true
  @current_turn_index = 0
  @combat_order = @combat_order.sort_by { |a| @entities[a][:initiative] || a.name }.reverse
end
tpk?() click to toggle source
# File lib/natural_20/battle.rb, line 474
def tpk?
  @current_party && !@current_party.detect(&:conscious?)
end
trigger_event!(event, source, opt = {}) click to toggle source
# File lib/natural_20/battle.rb, line 523
def trigger_event!(event, source, opt = {})
  @battle_field_events[event.to_sym]&.each do |object, handler|
    object.send(handler, self, source, opt.merge(ui_controller: controller_for(source)))
  end
  @map.activate_map_triggers(event, source, opt.merge(ui_controller: controller_for(source)))
end
trigger_opportunity_attack(entity, target, cur_x, cur_y) click to toggle source
# File lib/natural_20/battle.rb, line 492
def trigger_opportunity_attack(entity, target, cur_x, cur_y)
  event = {
    target: target,
    position: [cur_x, cur_y]
  }
  action = entity.trigger_event(:opportunity_attack, self, @session, @map, event)
  if action
    action.resolve(@session, @map, battle: self)
    commit(action)
  end
end
two_weapon_attack?(entity) click to toggle source
# File lib/natural_20/battle.rb, line 57
def two_weapon_attack?(entity)
  !!entity_state_for(entity)[:two_weapon]
end
update_group_dynamics(opposing_groups) click to toggle source

Updates opposing player groups to determine who is the enemy of who @param opposing_groups [Hash{Symbol=>Array<Symbol>}]

# File lib/natural_20/battle.rb, line 48
def update_group_dynamics(opposing_groups)
  @opposing_groups = opposing_groups
end
valid_targets_for(entity, action, target_types: [:enemies], range: nil, active_perception: nil, include_objects: false, filter: nil) click to toggle source

Generates targets that make sense for a given action @param entity [Natural20::Entity] @param action [Natural20::Action] @param filter [String] @option target_types [Array<Symbol>] @return [Natural20::Entity]

# File lib/natural_20/battle.rb, line 230
def valid_targets_for(entity, action, target_types: [:enemies], range: nil, active_perception: nil, include_objects: false, filter: nil)
  raise 'not an action' unless action.is_a?(Natural20::Action)

  active_perception = active_perception.nil? ? active_perception_for(entity) : 0
  target_types = target_types&.map(&:to_sym) || [:enemies]
  entity_group = @entities[entity][:group]
  attack_range = compute_max_weapon_range(action, range)

  raise 'attack range cannot be nil' if attack_range.nil?

  targets = @entities.map do |k, prop|
    next if !target_types.include?(:self) && k == entity
    next if !target_types.include?(:allies) && prop[:group] == entity_group && k != entity
    next if !target_types.include?(:enemies) && opposing?(entity, k)
    next if k.dead?
    next if k.hp.nil?
    next if !target_types.include?(:ignore_los) && !can_see?(entity, k, active_perception: active_perception)
    next if @map.distance(k, entity) * @map.feet_per_grid > attack_range
    next if filter && !k.eval_if(filter)

    action.target = k
    action.validate
    next unless action.errors.empty?

    k
  end.compact

  if include_objects
    targets += @map.interactable_objects.map do |object, _position|
      next if object.dead?
      next if !target_types.include?(:ignore_los) && !can_see?(entity, object, active_perception: active_perception)
      next if @map.distance(object, entity) * @map.feet_per_grid > attack_range
      next if filter && !k.eval_if(filter)

      object
    end.compact
  end

  targets
end
while_active(max_rounds = nil, &block) click to toggle source
# File lib/natural_20/battle.rb, line 384
def while_active(max_rounds = nil, &block)
  loop do
    Natural20::EventManager.received_event(source: self, event: :start_of_round, in_battle: ongoing?)

    current_turn.death_saving_throw!(self) if current_turn.unconscious? && !current_turn.stable?

    if current_turn.conscious?
      current_turn.reset_turn!(self)
      next if block.call(current_turn)
    end

    return :tpk if tpk?

    trigger_event!(:end_of_round, self, target: current_turn)

    if @started && battle_ends?
      Natural20::EventManager.received_event(source: self, event: :end_of_combat)
      @started = false
    end

    @current_turn_index += 1
    next unless @current_turn_index >= @combat_order.length

    @current_turn_index = 0
    @round += 1

    # top of the round
    unless @late_comers.empty?
      @combat_order += @late_comers
      @late_comers.clear
      @combat_order = @combat_order.sort_by { |a| @entities[a][:initiative] || a.name }.reverse
    end
    session.increment_game_time!

    Natural20::EventManager.received_event({ source: self, event: :top_of_the_round, round: @round,
                                             target: current_turn })

    return if !max_rounds.nil? && @round > max_rounds
  end
end

Protected Instance Methods

check_action_serialization(action) click to toggle source
# File lib/natural_20/battle.rb, line 546
def check_action_serialization(action)
  action.result.each do |r|
    r.each do |k, v|
      next if v.is_a?(String)
      next if v.is_a?(Integer)
      next if [true, false].include?(v)
      next if v.is_a?(Numeric)

      raise "#{action.action_type} value #{k} -> #{v} not serializable!"
    end
  end
end
t(key, options = {}) click to toggle source
# File lib/natural_20/battle.rb, line 542
def t(key, options = {})
  I18n.t(key, **options)
end