natural_20 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +14 -0
  3. data/.rspec +3 -0
  4. data/.travis.yml +6 -0
  5. data/CODE_OF_CONDUCT.md +74 -0
  6. data/Gemfile +7 -0
  7. data/LICENSE.txt +21 -0
  8. data/README.md +99 -0
  9. data/Rakefile +6 -0
  10. data/bin/compute_lights +19 -0
  11. data/bin/console +19 -0
  12. data/bin/nat20 +135 -0
  13. data/bin/nat20.cmd +3 -0
  14. data/bin/nat20author +104 -0
  15. data/bin/setup +8 -0
  16. data/char_classes/fighter.yml +45 -0
  17. data/char_classes/rogue.yml +54 -0
  18. data/characters/halfling_rogue.yml +46 -0
  19. data/characters/high_elf_fighter.yml +49 -0
  20. data/fixtures/battle_sim.yml +58 -0
  21. data/fixtures/battle_sim_2.yml +30 -0
  22. data/fixtures/battle_sim_3.yml +26 -0
  23. data/fixtures/battle_sim_4.yml +26 -0
  24. data/fixtures/battle_sim_objects.yml +101 -0
  25. data/fixtures/corridors.yml +24 -0
  26. data/fixtures/elf_rogue.yml +39 -0
  27. data/fixtures/halfling_rogue.yml +41 -0
  28. data/fixtures/high_elf_fighter.yml +49 -0
  29. data/fixtures/human_fighter.yml +48 -0
  30. data/fixtures/path_finding_test.yml +11 -0
  31. data/fixtures/path_finding_test_2.yml +15 -0
  32. data/fixtures/path_finding_test_3.yml +26 -0
  33. data/fixtures/thin_walls.yml +53 -0
  34. data/fixtures/traps.yml +25 -0
  35. data/game.yml +20 -0
  36. data/items/equipment.yml +101 -0
  37. data/items/objects.yml +73 -0
  38. data/items/weapons.yml +297 -0
  39. data/lib/natural_20.rb +68 -0
  40. data/lib/natural_20/actions/action.rb +40 -0
  41. data/lib/natural_20/actions/attack_action.rb +372 -0
  42. data/lib/natural_20/actions/concerns/action_damage.rb +14 -0
  43. data/lib/natural_20/actions/dash_action.rb +46 -0
  44. data/lib/natural_20/actions/disengage_action.rb +53 -0
  45. data/lib/natural_20/actions/dodge_action.rb +45 -0
  46. data/lib/natural_20/actions/escape_grapple_action.rb +97 -0
  47. data/lib/natural_20/actions/first_aid_action.rb +109 -0
  48. data/lib/natural_20/actions/grapple_action.rb +185 -0
  49. data/lib/natural_20/actions/ground_interact_action.rb +74 -0
  50. data/lib/natural_20/actions/help_action.rb +56 -0
  51. data/lib/natural_20/actions/hide_action.rb +53 -0
  52. data/lib/natural_20/actions/interact_action.rb +91 -0
  53. data/lib/natural_20/actions/inventory_action.rb +23 -0
  54. data/lib/natural_20/actions/look_action.rb +63 -0
  55. data/lib/natural_20/actions/move_action.rb +254 -0
  56. data/lib/natural_20/actions/multiattack_action.rb +41 -0
  57. data/lib/natural_20/actions/prone_action.rb +38 -0
  58. data/lib/natural_20/actions/short_rest_action.rb +53 -0
  59. data/lib/natural_20/actions/shove_action.rb +142 -0
  60. data/lib/natural_20/actions/stand_action.rb +47 -0
  61. data/lib/natural_20/actions/use_item_action.rb +57 -0
  62. data/lib/natural_20/ai_controller/path_compute.rb +140 -0
  63. data/lib/natural_20/ai_controller/standard.rb +288 -0
  64. data/lib/natural_20/battle.rb +544 -0
  65. data/lib/natural_20/battle_map.rb +843 -0
  66. data/lib/natural_20/cli/builder/fighter_builder.rb +104 -0
  67. data/lib/natural_20/cli/builder/rogue_builder.rb +62 -0
  68. data/lib/natural_20/cli/character_builder.rb +210 -0
  69. data/lib/natural_20/cli/commandline_ui.rb +612 -0
  70. data/lib/natural_20/cli/inventory_ui.rb +136 -0
  71. data/lib/natural_20/cli/map_renderer.rb +165 -0
  72. data/lib/natural_20/concerns/container.rb +32 -0
  73. data/lib/natural_20/concerns/entity.rb +1213 -0
  74. data/lib/natural_20/concerns/evaluator/entity_state_evaluator.rb +59 -0
  75. data/lib/natural_20/concerns/fighter_actions/second_wind_action.rb +51 -0
  76. data/lib/natural_20/concerns/fighter_class.rb +35 -0
  77. data/lib/natural_20/concerns/health_flavor.rb +27 -0
  78. data/lib/natural_20/concerns/lootable.rb +94 -0
  79. data/lib/natural_20/concerns/movement_helper.rb +195 -0
  80. data/lib/natural_20/concerns/multiattack.rb +54 -0
  81. data/lib/natural_20/concerns/navigation.rb +87 -0
  82. data/lib/natural_20/concerns/notable.rb +37 -0
  83. data/lib/natural_20/concerns/rogue_class.rb +26 -0
  84. data/lib/natural_20/controller.rb +11 -0
  85. data/lib/natural_20/die_roll.rb +331 -0
  86. data/lib/natural_20/event_manager.rb +288 -0
  87. data/lib/natural_20/item_library/base_item.rb +27 -0
  88. data/lib/natural_20/item_library/chest.rb +230 -0
  89. data/lib/natural_20/item_library/door_object.rb +189 -0
  90. data/lib/natural_20/item_library/ground.rb +124 -0
  91. data/lib/natural_20/item_library/healing_potion.rb +51 -0
  92. data/lib/natural_20/item_library/object.rb +153 -0
  93. data/lib/natural_20/item_library/pit_trap.rb +69 -0
  94. data/lib/natural_20/item_library/stone_wall.rb +18 -0
  95. data/lib/natural_20/npc.rb +173 -0
  96. data/lib/natural_20/player_character.rb +414 -0
  97. data/lib/natural_20/session.rb +168 -0
  98. data/lib/natural_20/utils/cover.rb +35 -0
  99. data/lib/natural_20/utils/ray_tracer.rb +90 -0
  100. data/lib/natural_20/utils/static_light_builder.rb +72 -0
  101. data/lib/natural_20/utils/weapons.rb +78 -0
  102. data/lib/natural_20/version.rb +4 -0
  103. data/locales/en.yml +304 -0
  104. data/maps/game_map.yml +168 -0
  105. data/natural_20.gemspec +46 -0
  106. data/npcs/goblin.yml +64 -0
  107. data/npcs/human_guard.yml +48 -0
  108. data/npcs/ogre.yml +61 -0
  109. data/npcs/owlbear.yml +55 -0
  110. data/npcs/wolf.yml +46 -0
  111. data/races/elf.yml +44 -0
  112. data/races/halfling.yml +22 -0
  113. data/races/human.yml +13 -0
  114. metadata +373 -0
@@ -0,0 +1,288 @@
1
+ # typed: false
2
+ module AiController
3
+ # Class used for handling "Standard" NPC AI
4
+ class Standard < Natural20::Controller
5
+ include Natural20::MovementHelper
6
+ include Natural20::Navigation
7
+
8
+ attr_reader :battle_data
9
+
10
+ def initialize
11
+ @battle_data = {}
12
+ end
13
+
14
+ def sound_listener(battle, entity, position, stealth); end
15
+
16
+ # @param battle [Natural20::Battle]
17
+ # @param entity [Natural20::Entity]
18
+ # @param position [Array]
19
+ def movement_listener(battle, entity, position)
20
+ move_path = position[:move_path]
21
+
22
+ return if move_path.nil?
23
+
24
+ # handle unaware npcs
25
+ new_npcs = battle.map.unaware_npcs.map do |unaware_npc_info|
26
+ npc = unaware_npc_info[:entity]
27
+ seen = !!move_path.reverse.detect do |path|
28
+ npc.conscious? && battle.map.can_see?(npc, entity, entity_2_pos: path)
29
+ end
30
+
31
+ next unless seen
32
+
33
+ register_handlers_on(npc) # attach this AI controller to this NPC
34
+ battle.add(npc, unaware_npc_info[:group])
35
+ npc
36
+ end.compact
37
+ new_npcs.each { |npc| battle.map.unaware_npcs.delete(npc) }
38
+
39
+ # update seen info for each npc
40
+ battle.entities.each_key do |e|
41
+ loc = move_path.reverse.detect do |location|
42
+ battle.map.can_see?(entity, e, entity_2_pos: location)
43
+ end
44
+ # include friendlies as well since they can turn on us at one point in time :)
45
+ update_enemy_known_position(e, entity, *loc) if loc
46
+ end
47
+ end
48
+
49
+ def attack_listener(battle, target)
50
+ unaware_npc_info = battle.map.unaware_npcs.detect { |n| n[:entity] == target }
51
+ return unless unaware_npc_info
52
+
53
+ register_handlers_on(target) # attach this AI controller to this NPC
54
+ battle.add(target, unaware_npc_info[:group])
55
+ battle.map.unaware_npcs.delete(target)
56
+ end
57
+
58
+ # @param battle [Natural20::Battle]
59
+ # @param action [Natural20::Action]
60
+ def action_listener(battle, action, _opt)
61
+ # handle unaware npcs
62
+ new_npcs = battle.map.unaware_npcs.map do |unaware_npc_info|
63
+ npc = unaware_npc_info[:entity]
64
+ next unless npc.conscious?
65
+ next unless battle.map.can_see?(npc, action.source)
66
+
67
+ register_handlers_on(npc) # attach this AI controller to this NPC
68
+ battle.add(npc, unaware_npc_info[:group])
69
+ update_enemy_known_position(battle, npc, action.source, battle.map.entity_squares(action.source).first)
70
+ npc
71
+ end
72
+ new_npcs.each { |npc| battle.map.unaware_npcs.delete(npc) }
73
+ end
74
+
75
+ def opportunity_attack_listener(battle, session, entity, map, event)
76
+ entity_x, entity_y = map.position_of(entity)
77
+ target_x, target_y = event[:position]
78
+
79
+ distance = Math.sqrt((target_x - entity_x)**2 + (target_y - entity_y)**2).ceil
80
+
81
+ action = entity.available_actions(session, battle, opportunity_attack: true).select do |s|
82
+ distance <= s.npc_action[:range]
83
+ end.first
84
+
85
+ if action
86
+ action.target = event[:target]
87
+ action.as_reaction = true
88
+ end
89
+ action
90
+ end
91
+
92
+ def register_battle_listeners(battle)
93
+ # detects noisy things
94
+ battle.add_battlefield_event_listener(:sound, self, :sound_listener)
95
+
96
+ # detects line of sight movement
97
+ battle.add_battlefield_event_listener(:movement, self, :movement_listener)
98
+
99
+ # actions listener (check if doors opened etc)
100
+ battle.add_battlefield_event_listener(:interact, self, :action_listener)
101
+ end
102
+
103
+ # @param entity [Natural20::Entity]
104
+ def register_handlers_on(entity)
105
+ entity.attach_handler(:opportunity_attack, self, :opportunity_attack_listener)
106
+ end
107
+
108
+ # tests if npc has an appropriate weapon to at least one visible enemy
109
+ def has_appropriate_weapon?; end
110
+
111
+ # Tells AI to compute moves for an entity
112
+ # @param entity [Natural20::Entity] The entity to compute moves for
113
+ # @param battle [Natural20::Battle] An instance of the current battle
114
+ # @return [Array]
115
+ def move_for(entity, battle)
116
+ initialize_battle_data(battle, entity)
117
+
118
+ known_enemy_positions = @battle_data[battle][entity][:known_enemy_positions]
119
+ hiding_spots = @battle_data[battle][entity][:hiding_spots]
120
+ investigate_location = @battle_data[battle][entity][:investigate_location]
121
+
122
+ enemy_positions = {}
123
+ observe_enemies(battle, entity, enemy_positions)
124
+
125
+ available_actions = entity.available_actions(@session, battle)
126
+
127
+ # generate available targets
128
+ valid_actions = []
129
+
130
+ if enemy_positions.empty? && LookAction.can?(entity, battle)
131
+ action = LookAction.new(battle.session, entity, :look)
132
+ return action
133
+ end
134
+
135
+ # try to stand if prone
136
+ valid_actions << StandAction.new(@session, entity, :stand) if entity.prone? && StandAction.can?(entity, battle)
137
+
138
+ available_actions.select { |a| a.action_type == :attack }.each do |action|
139
+ next unless action.npc_action
140
+
141
+ valid_targets = battle.valid_targets_for(entity, action)
142
+ unless valid_targets.first.nil?
143
+ action.target = valid_targets.first
144
+ valid_actions << action
145
+ end
146
+ end
147
+
148
+ # movement planner if no more attack options and enemies are in sight
149
+ if valid_actions.empty? && !enemy_positions.empty?
150
+ valid_actions += generate_moves_for_positions(battle, entity, enemy_positions)
151
+ end
152
+
153
+ # attempt to investigate last seen positions
154
+ if enemy_positions.empty?
155
+ my_group = battle.entity_group_for(entity)
156
+ investigate_location = known_enemy_positions.map do |enemy, position|
157
+ group = battle.entity_group_for(enemy)
158
+ next if my_group == group
159
+
160
+ [enemy, position]
161
+ end.compact.to_h
162
+
163
+ valid_actions += generate_moves_for_positions(battle, entity, investigate_location)
164
+ end
165
+
166
+ valid_actions << DodgeAction.new(battle.session, entity, :dodge) if entity.action?(battle)
167
+
168
+ return valid_actions.first unless valid_actions.empty?
169
+ end
170
+
171
+ protected
172
+
173
+ # @param battle [Natural20::Battle]
174
+ # @param entity [Natural20::Entity]
175
+ # @param enemy_positions [Hash]
176
+ # @param use_dash [Boolean]
177
+ def generate_moves_for_positions(battle, entity, enemy_positions, use_dash: false)
178
+ valid_actions = []
179
+
180
+ path_compute = PathCompute.new(battle, battle.map, entity)
181
+ start_x, start_y = battle.map.position_of(entity)
182
+
183
+ target_squares = evaluate_square(battle.map, battle, entity, enemy_positions.keys)
184
+
185
+ squares_priority = target_squares.map do |square, static_eval|
186
+ range_weight = 1.0
187
+ melee_weight = 1.0
188
+ defense_weight = 1.0
189
+ mobolity_weight = 1.0
190
+ melee, ranged, defense, mobility, _support = static_eval
191
+
192
+ if has_ranged_weapon?(entity)
193
+ range_weight = 2.0
194
+ else
195
+ melee_weight = 2.0
196
+ end
197
+
198
+ defense_weight = 2.0 if (entity.hp / entity.max_hp) < 0.25
199
+
200
+ [square, melee_weight * melee + range_weight * ranged + defense_weight * defense + mobility * mobolity_weight]
201
+ end
202
+
203
+ chosen_path = nil
204
+
205
+ squares_priority.sort_by! { |a| a[1] }.reverse!.each do |t|
206
+ return [] if t[0] == [start_x, start_y] # AI thinks its best to not move
207
+
208
+ path = path_compute.compute_path(start_x, start_y, *t[0])
209
+ next if path.nil?
210
+
211
+ chosen_path = path
212
+ break
213
+ end
214
+
215
+ return [] if chosen_path.nil? || chosen_path.empty?
216
+
217
+ if entity.available_movement(battle).zero? && use_dash
218
+ if DashBonusAction.can?(entity, battle)
219
+ action DashBonusAction.new(battle.session, entity, :dash_bonus)
220
+ action.as_bonus_action = true
221
+ valid_actions << action
222
+ elsif DashAction.can?(entity, battle)
223
+ valid_actions << DashAction.new(battle.session, entity, :dash)
224
+ end
225
+ elsif MoveAction.can?(entity, battle)
226
+ move_action = MoveAction.new(battle.session, entity, :move)
227
+ move_budget = entity.available_movement(battle) / battle.map.feet_per_grid
228
+ shortest_path = compute_actual_moves(entity, chosen_path, battle.map, battle, move_budget).movement
229
+ return [] if shortest_path.size.zero? || shortest_path.size == 1
230
+
231
+ move_action.move_path = shortest_path
232
+ valid_actions << move_action
233
+ end
234
+
235
+ valid_actions
236
+ end
237
+
238
+ # gain information about enemies in a fair and realistic way (e.g. using line of sight)
239
+ # @param battle [Natural20::Battle]
240
+ # @param entity [Natural20::Entity]
241
+ def observe_enemies(battle, entity, enemy_positions = {})
242
+ objects_around_me = battle.map.look(entity)
243
+
244
+ my_group = battle.entity_group_for(entity)
245
+
246
+ objects_around_me.each do |object, location|
247
+ group = battle.entity_group_for(object)
248
+ next if group == :none
249
+ next unless group
250
+ next unless object.conscious?
251
+
252
+ enemy_positions[object] = location if group != my_group
253
+ end
254
+ end
255
+
256
+ def has_ranged_weapon?(entity)
257
+ if entity.npc?
258
+ entity.npc_actions.detect do |npc_action|
259
+ next if npc_action[:ammo] && entity.item_count(npc_action[:ammo]) <= 0
260
+ next if npc_action[:if] && !entity.eval_if(npc_action[:if])
261
+
262
+ npc_action[:type] == 'ranged_attack'
263
+ end
264
+ else
265
+ # TODO: Player character
266
+ end
267
+ end
268
+
269
+ # @param battle [Natural20::Battle]
270
+ # @param entity [Natural20::Entity]
271
+ # @param enemy [Natural20::Entity]
272
+ # @param position [Array]
273
+ def update_enemy_known_position(battle, entity, enemy, position)
274
+ initialize_battle_data(battle, entity)
275
+
276
+ @battle_data[battle][entity][:known_enemy_positions][enemy] = position
277
+ end
278
+
279
+ def initialize_battle_data(battle, entity)
280
+ @battle_data[battle] ||= {}
281
+ @battle_data[battle][entity] ||= {
282
+ known_enemy_positions: {},
283
+ hiding_spots: {},
284
+ investigate_location: {}
285
+ }
286
+ end
287
+ end
288
+ end
@@ -0,0 +1,544 @@
1
+ # typed: false
2
+ module Natural20
3
+ class Battle
4
+ attr_accessor :combat_order, :round, :current_party
5
+ attr_reader :map, :entities, :session, :battle_log, :started, :in_combat
6
+
7
+ # Create an instance of a battle
8
+ # @param session [Natural20::Session]
9
+ # @param map [Natural20::BattleMap]
10
+ # @param standard_controller [AiController::Standard]
11
+ def initialize(session, map, standard_controller = nil)
12
+ @session = session
13
+ @entities = {}
14
+ @groups = {}
15
+ @battle_field_events = {}
16
+ @battle_log = []
17
+ @combat_order = []
18
+ @late_comers = []
19
+ @current_turn_index = 0
20
+ @round = 0
21
+ @map = map
22
+ @in_combat = false
23
+ @standard_controller = standard_controller
24
+
25
+ @opposing_groups = {
26
+ a: [:b],
27
+ b: [:a]
28
+ }
29
+
30
+ standard_controller&.register_battle_listeners(self)
31
+ end
32
+
33
+ # Registers a player party
34
+ # @param party [Array<Natural20::Entity>] Initial Player character party
35
+ # @param controller [Natural20::Controller] An object to be used as callback controller
36
+ def register_players(party, controller)
37
+ return if party.blank?
38
+
39
+ party.each_with_index do |pc, index|
40
+ add(pc, :a, position: "spawn_point_#{index + 1}", controller: controller)
41
+ end
42
+ @current_party = party
43
+ @combat_order = party
44
+ end
45
+
46
+ # Updates opposing player groups to determine who is the enemy of who
47
+ # @param opposing_groups [Hash{Symbol=>Array<Symbol>}]
48
+ def update_group_dynamics(opposing_groups)
49
+ @opposing_groups = opposing_groups
50
+ end
51
+
52
+ def add_battlefield_event_listener(event, object, handler)
53
+ @battle_field_events[event.to_sym] ||= []
54
+ @battle_field_events[event.to_sym] << [object, handler]
55
+ end
56
+
57
+ def two_weapon_attack?(entity)
58
+ !!entity_state_for(entity)[:two_weapon]
59
+ end
60
+
61
+ def first_hand_weapon(entity)
62
+ entity_state_for(entity)[:two_weapon]
63
+ end
64
+
65
+ # Adds an entity to the battle
66
+ # @param entity [Natural20::Entity] The entity to add to the battle
67
+ # @param group [Symbol] A symbol denoting which group this entity belongs to
68
+ # @param controller [AiController::Standard] Ai controller to use
69
+ # @param position [Array, Symbol] Starting location in the map can be a position or a spawn point
70
+ # @param token [String, Symbol] The token to use
71
+ def add(entity, group, controller: nil, position: nil, token: nil)
72
+ return if @entities[entity]
73
+
74
+ raise 'entity cannot be nil' if entity.nil?
75
+
76
+ state = {
77
+ group: group,
78
+ action: 0,
79
+ bonus_action: 0,
80
+ reaction: 0,
81
+ movement: 0,
82
+ stealth: 0,
83
+ statuses: Set.new,
84
+ active_perception: 0,
85
+ active_perception_disadvantage: 0,
86
+ free_object_interaction: 0,
87
+ target_effect: {},
88
+ two_weapon: nil,
89
+ controller: controller || @standard_controller
90
+ }
91
+
92
+ @entities[entity] = state
93
+
94
+ battle_defaults = entity.try(:battle_defaults)
95
+ if battle_defaults
96
+ battle_defaults[:statuses].each { |s| state[:statuses].add(s.to_sym) }
97
+ unless state[:stealth].blank?
98
+ state[:stealth] =
99
+ DieRoll.roll(battle_defaults[:stealth], description: t('dice_roll.stealth'), entity: entity,
100
+ battle: self).result
101
+ end
102
+ end
103
+
104
+ @groups[group] ||= Set.new
105
+ @groups[group].add(entity)
106
+
107
+ # battle already ongoing...
108
+ if started
109
+ @late_comers << entity
110
+ @entities[entity][:initiative] = entity.initiative!(self)
111
+ end
112
+
113
+ return if position.nil?
114
+ return if @map.nil?
115
+
116
+ if position.is_a?(Array)
117
+ @map.place(*position, entity, token,
118
+ self)
119
+ else
120
+ @map.place_at_spawn_point(position, entity, token)
121
+ end
122
+ end
123
+
124
+ def in_battle?(entity)
125
+ @entities.key?(entity)
126
+ end
127
+
128
+ def move_for(entity)
129
+ @entities[entity][:controller].move_for(entity, self)
130
+ end
131
+
132
+ def controller_for(entity)
133
+ return nil unless @entities.key? entity
134
+
135
+ @entities[entity][:controller]
136
+ end
137
+
138
+ def roll_for(entity, die_type, number_of_times, description, advantage: false, disadvantage: false)
139
+ controller = if @entities[entity] && @entities[entity][:controller]
140
+ @entities[entity][:controller]
141
+ else
142
+ @standard_controller
143
+ end
144
+ rolls = controller.try(:roll_for, entity, die_type, number_of_times, description,
145
+ advantage: advantage, disadvantage: disadvantage)
146
+ return rolls if rolls
147
+
148
+ if advantage || disadvantage
149
+ number_of_times.times.map { [(1..die_type).to_a.sample, (1..die_type).to_a.sample] }
150
+ else
151
+ number_of_times.times.map { (1..die_type).to_a.sample }
152
+ end
153
+ end
154
+
155
+ # @param entity [Natural20::Entity]
156
+ # @return [Hash]
157
+ def entity_state_for(entity)
158
+ @entities[entity]
159
+ end
160
+
161
+ def entity_group_for(entity)
162
+ return :none unless @entities[entity]
163
+
164
+ @entities[entity][:group]
165
+ end
166
+
167
+ def dismiss_help_actions_for(source)
168
+ @entities.each do |_k, entity|
169
+ entity[:target_effect]&.delete(source) if %i[help help_ability_check].include?(entity[:target_effect][source])
170
+ end
171
+ end
172
+
173
+ def help_with?(target)
174
+ return @entities[target][:target_effect].values.include?(:help) if @entities[target]
175
+
176
+ false
177
+ end
178
+
179
+ def dismiss_help_for(target)
180
+ return unless @entities[target]
181
+
182
+ @entities[target][:target_effect].delete_if { |_k, v| v == :help }
183
+ end
184
+
185
+ def action(source, action_type, opts = {})
186
+ action = source.available_actions(@session, self).detect { |act| act.action_type == action_type }
187
+ opts[:battle] = self
188
+ return action.resolve(@session, @map, opts) if action
189
+
190
+ nil
191
+ end
192
+
193
+ def action!(action)
194
+ opts = {
195
+ battle: self
196
+ }
197
+ action.resolve(@session, @map, opts)
198
+ end
199
+
200
+ def compute_max_weapon_range(action, range = nil)
201
+ case action.action_type
202
+ when :help
203
+ 5
204
+ when :attack
205
+ if action.npc_action
206
+ action.npc_action[:range_max].presence || action.npc_action[:range]
207
+ elsif action.using
208
+ weapon = session.load_weapon(action.using)
209
+ if action.thrown
210
+ weapon.dig(:thrown, :range_max) || weapon.dig(:thrown, :range) || weapon[:range]
211
+ else
212
+ weapon[:range_max].presence || weapon[:range]
213
+ end
214
+ end
215
+ else
216
+ range
217
+ end
218
+ end
219
+
220
+ def active_perception_for(entity)
221
+ @entities[entity][:active_perception] || 0
222
+ end
223
+
224
+ # Generates targets that make sense for a given action
225
+ # @param entity [Natural20::Entity]
226
+ # @param action [Natural20::Action]
227
+ # @param filter [String]
228
+ # @option target_types [Array<Symbol>]
229
+ # @return [Natural20::Entity]
230
+ def valid_targets_for(entity, action, target_types: [:enemies], range: nil, active_perception: nil, include_objects: false, filter: nil)
231
+ raise 'not an action' unless action.is_a?(Natural20::Action)
232
+
233
+ active_perception = active_perception.nil? ? active_perception_for(entity) : 0
234
+ target_types = target_types&.map(&:to_sym) || [:enemies]
235
+ entity_group = @entities[entity][:group]
236
+ attack_range = compute_max_weapon_range(action, range)
237
+
238
+ raise 'attack range cannot be nil' if attack_range.nil?
239
+
240
+ targets = @entities.map do |k, prop|
241
+ next if !target_types.include?(:self) && k == entity
242
+ next if !target_types.include?(:allies) && prop[:group] == entity_group && k != entity
243
+ next if !target_types.include?(:enemies) && opposing?(entity, k)
244
+ next if k.dead?
245
+ next if k.hp.nil?
246
+ next if !target_types.include?(:ignore_los) && !can_see?(entity, k, active_perception: active_perception)
247
+ next if @map.distance(k, entity) * @map.feet_per_grid > attack_range
248
+ next if filter && !k.eval_if(filter)
249
+
250
+ action.target = k
251
+ action.validate
252
+ next unless action.errors.empty?
253
+
254
+ k
255
+ end.compact
256
+
257
+ if include_objects
258
+ targets += @map.interactable_objects.map do |object, _position|
259
+ next if object.dead?
260
+ next if !target_types.include?(:ignore_los) && !can_see?(entity, object, active_perception: active_perception)
261
+ next if @map.distance(object, entity) * @map.feet_per_grid > attack_range
262
+ next if filter && !k.eval_if(filter)
263
+
264
+ object
265
+ end.compact
266
+ end
267
+
268
+ targets
269
+ end
270
+
271
+ # Determines if an entity can see someone in battle
272
+ # @param entity1 [Natural20::Entity] observer
273
+ # @param entity2 [Natural20::Entity] entity being observed
274
+ # @return [Boolean]
275
+ def can_see?(entity1, entity2, active_perception: 0, entity_1_pos: nil, entity_2_pos: nil)
276
+ return true if entity1 == entity2
277
+ return false unless @map.can_see?(entity1, entity2, entity_1_pos: entity_1_pos, entity_2_pos: entity_2_pos)
278
+ return true unless entity2.hiding?(self)
279
+
280
+ cover_value = @map.cover_calculation(@map, entity1, entity2, entity_1_pos: entity_1_pos,
281
+ naturally_stealthy: entity2.class_feature?('naturally_stealthy'))
282
+ if cover_value.positive?
283
+ entity_2_state = entity_state_for(entity2)
284
+ return false if entity_2_state[:stealth] > [active_perception, entity1.passive_perception].max
285
+ end
286
+
287
+ true
288
+ end
289
+
290
+ # Retruns opponents of entity
291
+ # @param entity [Natural20::Entity] target entity
292
+ # @return [Array<Natrual20::Entity>]
293
+ def opponents_of?(entity)
294
+ (@entities.keys + @late_comers).reject(&:dead?).select do |k|
295
+ opposing?(k, entity)
296
+ end
297
+ end
298
+
299
+ # Determines if two entities are opponents of each other
300
+ # @param entity1 [Natural20::Entity]
301
+ # @param entity2 [Natural20::Entity]
302
+ # @return [Boolean]
303
+ def opposing?(entity1, entity2)
304
+ source_state1 = entity_state_for(entity1)
305
+ source_state2 = entity_state_for(entity2)
306
+ return false if source_state1.nil? || source_state2.nil?
307
+
308
+ source_group1 = source_state1[:group]
309
+ source_group2 = source_state2[:group]
310
+
311
+ @opposing_groups[source_group1]&.include?(source_group2)
312
+ end
313
+
314
+ # Determines if two entities are allies of each other
315
+ # @param entity1 [Natural20::Entity]
316
+ # @param entity2 [Natural20::Entity]
317
+ # @return [Boolean]
318
+ def allies?(entity1, entity2)
319
+ source_state1 = entity_state_for(entity1)
320
+ source_state2 = entity_state_for(entity2)
321
+ return false if source_state1.nil? || source_state2.nil?
322
+
323
+ source_group1 = source_state1[:group]
324
+ source_group2 = source_state2[:group]
325
+
326
+ source_group1 == source_group2
327
+ end
328
+
329
+ # Checks if this entity is controlled by AI or Person
330
+ # @param entity [Natural20::Entity]
331
+ # @return [Boolean]
332
+ def has_controller_for?(entity)
333
+ raise 'unknown entity in battle' unless @entities.key?(entity)
334
+
335
+ @entities[entity][:controller] != :manual
336
+ end
337
+
338
+ # consume action resource and return if something changed
339
+ def consume!(entity, resource, qty)
340
+ current_qty = entity_state_for(entity)[resource.to_sym]
341
+ new_qty = [0, current_qty - qty].max
342
+ entity_state_for(entity)[resource.to_sym] = new_qty
343
+
344
+ current_qty != new_qty
345
+ end
346
+
347
+ # @param combat_order [Array] If specified will use this array as the initiative order
348
+ def start(combat_order = nil)
349
+ if combat_order
350
+ @combat_order = combat_order
351
+ return
352
+ end
353
+
354
+ # roll for initiative
355
+ @combat_order = @entities.map do |entity, v|
356
+ next if entity.dead?
357
+
358
+ v[:initiative] = entity.initiative!(self)
359
+
360
+ entity
361
+ end.compact
362
+
363
+ @started = true
364
+ @current_turn_index = 0
365
+ @combat_order = @combat_order.sort_by { |a| @entities[a][:initiative] || a.name }.reverse
366
+ end
367
+
368
+ # @return [Natural20::Entity]
369
+ def current_turn
370
+ @combat_order[@current_turn_index]
371
+ end
372
+
373
+ def check_combat
374
+ if !@started && !battle_ends?
375
+ start
376
+ Natural20::EventManager.received_event(source: self, event: :start_of_combat, target: current_turn,
377
+ combat_order: @combat_order.map { |e| [e, @entities[e][:initiative]] })
378
+ return true
379
+ end
380
+ false
381
+ end
382
+
383
+ def while_active(max_rounds = nil, &block)
384
+ loop do
385
+ Natural20::EventManager.received_event(source: self, event: :start_of_round)
386
+
387
+ current_turn.death_saving_throw!(self) if current_turn.unconscious? && !current_turn.stable?
388
+
389
+ if current_turn.conscious?
390
+ current_turn.reset_turn!(self)
391
+ next if block.call(current_turn)
392
+ end
393
+
394
+ return :tpk if tpk?
395
+
396
+ trigger_event!(:end_of_round, self, target: current_turn)
397
+
398
+ if @started && battle_ends?
399
+ Natural20::EventManager.received_event(source: self, event: :end_of_combat)
400
+ @started = false
401
+ end
402
+
403
+ @current_turn_index += 1
404
+ next unless @current_turn_index >= @combat_order.length
405
+
406
+ @current_turn_index = 0
407
+ @round += 1
408
+
409
+ # top of the round
410
+ unless @late_comers.empty?
411
+ @combat_order += @late_comers
412
+ @late_comers.clear
413
+ @combat_order = @combat_order.sort_by { |a| @entities[a][:initiative] || a.name }.reverse
414
+ end
415
+ session.increment_game_time!
416
+
417
+ Natural20::EventManager.received_event({ source: self, event: :top_of_the_round, round: @round,
418
+ target: current_turn })
419
+
420
+ return if !max_rounds.nil? && @round > max_rounds
421
+ end
422
+ end
423
+
424
+ # Determines if there is a conscious enemey within melee range
425
+ # @param source [Natural20::Entity]
426
+ # @return [Boolean]
427
+ def enemy_in_melee_range?(source, exclude = [], source_pos: nil)
428
+ objects_around_me = map.look(source)
429
+
430
+ objects_around_me.detect do |object, _|
431
+ next if exclude.include?(object)
432
+
433
+ state = entity_state_for(object)
434
+ next unless state
435
+ next unless object.conscious?
436
+
437
+ return true if opposing?(source, object) && (map.distance(source,
438
+ object, entity_1_pos: source_pos) <= (object.melee_distance / map.feet_per_grid))
439
+ end
440
+
441
+ false
442
+ end
443
+
444
+ # Determines if there is a conscious ally within melee range of target
445
+ # @param source [Natural20::Entity]
446
+ # @return [Boolean]
447
+ def ally_within_enemey_melee_range?(source, target, exclude = [], source_pos: nil)
448
+ objects_around_me = map.look(target)
449
+
450
+ objects_around_me.detect do |object, _|
451
+ next if exclude.include?(object)
452
+ next if object == source
453
+
454
+ state = entity_state_for(object)
455
+ next unless state
456
+ next unless object.conscious?
457
+
458
+ return true if allies?(source, object) && (map.distance(target,
459
+ object, entity_1_pos: source_pos) <= (object.melee_distance / map.feet_per_grid))
460
+ end
461
+
462
+ false
463
+ end
464
+
465
+ def ongoing?
466
+ @started
467
+ end
468
+
469
+ def combat?
470
+ ongoing?
471
+ end
472
+
473
+ def tpk?
474
+ @current_party && !@current_party.detect(&:conscious?)
475
+ end
476
+
477
+ def battle_ends?
478
+ groups_present = @entities.keys.reject do |a|
479
+ a.dead? || a.unconscious?
480
+ end.map { |e| @entities[e][:group] }.uniq
481
+ groups_present.each do |g|
482
+ groups_present.each do |h|
483
+ next if g == h
484
+ raise "warning unknown group #{g}" unless @opposing_groups[g]
485
+ return false if @opposing_groups[g].include?(h)
486
+ end
487
+ end
488
+ true
489
+ end
490
+
491
+ def trigger_opportunity_attack(entity, target, cur_x, cur_y)
492
+ event = {
493
+ target: target,
494
+ position: [cur_x, cur_y]
495
+ }
496
+ action = entity.trigger_event(:opportunity_attack, self, @session, @map, event)
497
+ if action
498
+ action.resolve(@session, @map, battle: self)
499
+ commit(action)
500
+ end
501
+ end
502
+
503
+ def commit(action)
504
+ return if action.nil?
505
+
506
+ # check_action_serialization(action)
507
+ action.apply!(self)
508
+
509
+ case action.action_type
510
+ when :move
511
+ trigger_event!(:movement, action.source, move_path: action.move_path)
512
+ when :interact
513
+ trigger_event!(:interact, action)
514
+ end
515
+ @battle_log << action
516
+ end
517
+
518
+ def trigger_event!(event, source, opt = {})
519
+ @battle_field_events[event.to_sym]&.each do |object, handler|
520
+ object.send(handler, self, source, opt.merge(ui_controller: controller_for(source)))
521
+ end
522
+ @map.activate_map_triggers(event, source, opt.merge(ui_controller: controller_for(source)))
523
+ end
524
+
525
+ protected
526
+
527
+ def t(key, options = {})
528
+ I18n.t(key, options)
529
+ end
530
+
531
+ def check_action_serialization(action)
532
+ action.result.each do |r|
533
+ r.each do |k, v|
534
+ next if v.is_a?(String)
535
+ next if v.is_a?(Integer)
536
+ next if [true, false].include?(v)
537
+ next if v.is_a?(Numeric)
538
+
539
+ raise "#{action.action_type} value #{k} -> #{v} not serializable!"
540
+ end
541
+ end
542
+ end
543
+ end
544
+ end