natural_20 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
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