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,372 @@
1
+ # typed: true
2
+ class AttackAction < Natural20::Action
3
+ include Natural20::Cover
4
+ include Natural20::Weapons
5
+ include Natural20::ActionDamage
6
+
7
+ attr_accessor :target, :using, :npc_action, :as_reaction, :thrown, :second_hand
8
+ attr_reader :advantage_mod
9
+
10
+ # @param entity [Natural20::Entity]
11
+ # @param battle [Natural20::Battle]
12
+ # @return [Boolean]
13
+ def self.can?(entity, battle, options = {})
14
+ battle.nil? || entity.total_actions(battle).positive? || (options[:opportunity_attack] && entity.total_reactions(battle).positive?) || entity.multiattack?(
15
+ battle, options[:npc_action]
16
+ )
17
+ end
18
+
19
+ def to_s
20
+ @action_type.to_s.humanize
21
+ end
22
+
23
+ def label
24
+ if @npc_action
25
+ t('action.npc_action', name: @action_type.to_s.humanize, action_name: npc_action[:name])
26
+ else
27
+ weapon = session.load_weapon(@opts[:using] || @using)
28
+ attack_mod = @source.attack_roll_mod(weapon)
29
+
30
+ i18n_token = thrown ? 'action.attack_action_throw' : 'action.attack_action'
31
+
32
+ t(i18n_token, name: @action_type.to_s.humanize, weapon_name: weapon[:name], mod: attack_mod,
33
+ dmg: damage_modifier(@source, weapon, second_hand: second_hand))
34
+ end
35
+ end
36
+
37
+ def build_map
38
+ OpenStruct.new({
39
+ action: self,
40
+ param: [
41
+ {
42
+ type: :select_target,
43
+ num: 1,
44
+ weapon: using
45
+ }
46
+ ],
47
+ next: lambda { |target|
48
+ self.target = target
49
+ OpenStruct.new({
50
+ param: [
51
+ { type: :select_weapon }
52
+ ],
53
+ next: lambda { |weapon|
54
+ self.using = weapon
55
+ OpenStruct.new({
56
+ param: nil,
57
+ next: -> { self }
58
+ })
59
+ }
60
+ })
61
+ }
62
+ })
63
+ end
64
+
65
+ def self.build(session, source)
66
+ action = AttackAction.new(session, source, :attack)
67
+ action.build_map
68
+ end
69
+
70
+ # @param battle [Natural20::Battle]
71
+ def apply!(battle)
72
+ @result.each do |item|
73
+ if item[:flavor]
74
+ Natural20::EventManager.received_event({ event: :flavor, source: item[:source], target: item[:target],
75
+ text: item[:flavor] })
76
+ end
77
+ case (item[:type])
78
+ when :prone
79
+ item[:source].prone!
80
+ when :damage
81
+ damage_event(item, battle)
82
+ when :miss
83
+ Natural20::EventManager.received_event({ attack_roll: item[:attack_roll],
84
+ attack_name: item[:attack_name],
85
+ advantage_mod: item[:advantage_mod],
86
+ as_reaction: !!as_reaction,
87
+ adv_info: item[:adv_info],
88
+ source: item[:source], target: item[:target], event: :miss })
89
+ end
90
+
91
+ # handle ammo
92
+ item[:source].deduct_item(item[:ammo], 1) if item[:ammo]
93
+
94
+ # hanle thrown items
95
+ if item[:thrown]
96
+ if item[:source].item_count(item[:weapon]).positive?
97
+ item[:source].deduct_item(item[:weapon], 1)
98
+ else
99
+ item[:source].unequip(item[:weapon], transfer_inventory: false)
100
+ end
101
+
102
+ if item[:type] == :damage
103
+ item[:target].add_item(item[:weapon])
104
+ else
105
+ ground_pos = item[:battle].map.entity_or_object_pos(item[:target])
106
+ ground_object = item[:battle].map.objects_at(*ground_pos).detect { |o| o.is_a?(ItemLibrary::Ground) }
107
+ ground_object&.add_item(item[:weapon])
108
+ end
109
+ end
110
+
111
+ if as_reaction
112
+ battle.entity_state_for(item[:source])[:reaction] -= 1
113
+ elsif item[:second_hand]
114
+ battle.entity_state_for(item[:source])[:bonus_action] -= 1
115
+ else
116
+ battle.entity_state_for(item[:source])[:action] -= 1
117
+ end
118
+
119
+ item[:source].break_stealth!(battle)
120
+
121
+ # handle two-weapon fighting
122
+ weapon = session.load_weapon(item[:weapon]) if item[:weapon]
123
+
124
+ if weapon && weapon[:properties]&.include?('light') && !battle.two_weapon_attack?(item[:source]) && !item[:second_hand]
125
+ battle.entity_state_for(item[:source])[:two_weapon] = item[:weapon]
126
+ else
127
+ battle.entity_state_for(item[:source])[:two_weapon] = nil
128
+ end
129
+
130
+ # handle multiattacks
131
+ battle.entity_state_for(item[:source])[:multiattack]&.each do |_group, attacks|
132
+ if attacks.include?(item[:attack_name])
133
+ attacks.delete(item[:attack_name])
134
+ item[:source].clear_multiattack!(battle) if attacks.empty?
135
+ end
136
+ end
137
+
138
+ # dismiss help actions
139
+ battle.dismiss_help_for(item[:target])
140
+ end
141
+ end
142
+
143
+ def with_advantage?
144
+ @advantage_mod.positive?
145
+ end
146
+
147
+ def with_disadvantage?
148
+ @advantage_mod.negative?
149
+ end
150
+
151
+ # Build the attack roll information
152
+ # @param session [Natural20::Session]
153
+ # @param map [Natural20::BattleMap]
154
+ # @option opts battle [Natural20::Battle]
155
+ # @option opts target [Natural20::Entity]
156
+ def resolve(_session, map, opts = {})
157
+ @result.clear
158
+ target = opts[:target] || @target
159
+ raise 'target is a required option for :attack' if target.nil?
160
+
161
+ npc_action = opts[:npc_action] || @npc_action
162
+ battle = opts[:battle]
163
+ using = opts[:using] || @using
164
+ raise 'using or npc_action is a required option for :attack' if using.nil? && npc_action.nil?
165
+
166
+ attack_name = nil
167
+ damage_roll = nil
168
+ sneak_attack_roll = nil
169
+ ammo_type = nil
170
+
171
+ npc_action = @source.npc_actions.detect { |a| a[:name].downcase == using.downcase } if @source.npc? && using
172
+
173
+ if npc_action
174
+ weapon = npc_action
175
+ attack_name = npc_action[:name]
176
+ attack_mod = npc_action[:attack]
177
+ damage_roll = npc_action[:damage_die]
178
+ ammo_type = npc_action[:ammo]
179
+ else
180
+ weapon = session.load_weapon(using.to_sym)
181
+ attack_name = weapon[:name]
182
+ ammo_type = weapon[:ammo]
183
+ attack_mod = @source.attack_roll_mod(weapon)
184
+ damage_roll = damage_modifier(@source, weapon, second_hand: second_hand)
185
+ end
186
+
187
+ # DnD 5e advantage/disadvantage checks
188
+ @advantage_mod, adv_info = target_advantage_condition(battle, @source, target, weapon)
189
+
190
+ # determine eligibility for the 'Protection' fighting style
191
+ evaluate_feature_protection(battle, map, target, adv_info) if map
192
+
193
+ # perform the dice rolls
194
+ attack_roll = Natural20::DieRoll.roll("1d20+#{attack_mod}", disadvantage: with_disadvantage?,
195
+ advantage: with_advantage?,
196
+ description: t('dice_roll.attack'), entity: @source, battle: battle)
197
+
198
+ # handle the lucky feat
199
+ attack_roll = attack_roll.reroll(lucky: true) if @source.class_feature?('lucky') && attack_roll.nat_1?
200
+
201
+ if @source.class_feature?('sneak_attack') && (weapon[:properties]&.include?('finesse') || weapon[:type] == 'ranged_attack') && (with_advantage? || battle.enemy_in_melee_range?(
202
+ target, [@source]
203
+ ))
204
+ sneak_attack_roll = Natural20::DieRoll.roll(@source.sneak_attack_level, crit: attack_roll.nat_20?,
205
+ description: t('dice_roll.sneak_attack'), entity: @source, battle: battle)
206
+ end
207
+
208
+ damage = Natural20::DieRoll.roll(damage_roll, crit: attack_roll.nat_20?, description: t('dice_roll.damage'),
209
+ entity: @source, battle: battle)
210
+
211
+ if @source.class_feature?('great_weapon_fighting') && (weapon[:properties]&.include?('two_handed') || (weapon[:properties]&.include?('versatile') && entity.used_hand_slots <= 1.0))
212
+ damage.rolls.map do |roll|
213
+ if [1, 2].include?(roll)
214
+ r = Natural20::DieRoll.roll("1d#{damage.die_sides}", description: t('dice_roll.great_weapon_fighting_reroll'),
215
+ entity: @source, battle: battle)
216
+ Natural20::EventManager.received_event({ roll: r, prev_roll: roll,
217
+ source: item[:source], event: :great_weapon_fighting_roll })
218
+ r.result
219
+ else
220
+ roll
221
+ end
222
+ end
223
+ end
224
+
225
+ # apply weapon bonus attacks
226
+ damage = check_weapon_bonuses(battle, weapon, damage, attack_roll)
227
+
228
+ cover_ac_adjustments = 0
229
+ hit = if attack_roll.nat_20?
230
+ true
231
+ elsif attack_roll.nat_1?
232
+ false
233
+ else
234
+ cover_ac_adjustments = calculate_cover_ac(battle.map, target) if battle.map
235
+ attack_roll.result >= (target.armor_class + cover_ac_adjustments)
236
+ end
237
+
238
+ if hit
239
+ @result << {
240
+ source: @source,
241
+ target: target,
242
+ type: :damage,
243
+ thrown: thrown,
244
+ weapon: using,
245
+ battle: battle,
246
+ advantage_mod: @advantage_mod,
247
+ damage_roll: damage_roll,
248
+ attack_name: attack_name,
249
+ attack_roll: attack_roll,
250
+ sneak_attack: sneak_attack_roll,
251
+ target_ac: target.armor_class,
252
+ cover_ac: cover_ac_adjustments,
253
+ adv_info: adv_info,
254
+ hit?: hit,
255
+ damage_type: weapon[:damage_type],
256
+ damage: damage,
257
+ ammo: ammo_type,
258
+ as_reaction: !!as_reaction,
259
+ second_hand: second_hand,
260
+ npc_action: npc_action
261
+ }
262
+ unless weapon[:on_hit].blank?
263
+ weapon[:on_hit].each do |effect|
264
+ next if effect[:if] && !@source.eval_if(effect[:if], weapon: weapon, target: target)
265
+
266
+ if effect[:save_dc]
267
+ save_type, dc = effect[:save_dc].split(':')
268
+ raise 'invalid values: save_dc should be of the form <save>:<dc>' if save_type.blank? || dc.blank?
269
+ raise 'invalid save type' unless Natural20::Entity::ATTRIBUTE_TYPES.include?(save_type)
270
+
271
+ save_roll = target.saving_throw!(save_type, battle: battle)
272
+ if save_roll.result >= dc.to_i
273
+ if effect[:success]
274
+ @result << target.apply_effect(effect[:success], battle: battle,
275
+ flavor: effect[:flavor_success])
276
+ end
277
+ elsif effect[:fail]
278
+ @result << target.apply_effect(effect[:fail], battle: battle, flavor: effect[:flavor_fail])
279
+ end
280
+ else
281
+ target.apply_effect(effect[:effect])
282
+ end
283
+ end
284
+ end
285
+ else
286
+ @result << {
287
+ attack_name: attack_name,
288
+ source: @source,
289
+ target: target,
290
+ weapon: using,
291
+ battle: battle,
292
+ thrown: thrown,
293
+ type: :miss,
294
+ advantage_mod: @advantage_mod,
295
+ adv_info: adv_info,
296
+ second_hand: second_hand,
297
+ damage_roll: damage_roll,
298
+ attack_roll: attack_roll,
299
+ as_reaction: !!as_reaction,
300
+ target_ac: target.armor_class,
301
+ cover_ac: cover_ac_adjustments,
302
+ ammo: ammo_type,
303
+ npc_action: npc_action
304
+ }
305
+ end
306
+
307
+ self
308
+ end
309
+
310
+ # Computes cover armor class adjustment
311
+ # @param map [Natural20::BattleMap]
312
+ # @param target [Natural20::Entity]
313
+ # @return [Integer]
314
+ def calculate_cover_ac(map, target)
315
+ cover_calculation(map, @source, target)
316
+ end
317
+
318
+ protected
319
+
320
+ # determine eligibility for the 'Protection' fighting style
321
+ def evaluate_feature_protection(battle, map, target, adv_info)
322
+ melee_sqaures = target.melee_squares(map, adjacent_only: true)
323
+ melee_sqaures.each do |pos|
324
+ entity = map.entity_at(*pos)
325
+ next if entity == @source
326
+ next if entity == target
327
+ next unless entity
328
+
329
+ next unless entity.class_feature?('protection') && entity.shield_equipped? && entity.has_reaction?(battle)
330
+
331
+ controller = battle.controller_for(entity)
332
+ if controller.respond_to?(:reaction) && !controller.reaction(:feature_protection, target: target,
333
+ source: entity, attacker: @source)
334
+ next
335
+ end
336
+
337
+ Natural20::EventManager.received_event(event: :feature_protection, target: target, source: entity,
338
+ attacker: @source)
339
+ _advantage, disadvantage = adv_info
340
+ disadvantage << :protection
341
+ @advantage_mod = -1
342
+ battle.entity_state_for(entity)[:reaction] -= 1
343
+ end
344
+ end
345
+
346
+ def check_weapon_bonuses(battle, weapon, damage_roll, attack_roll)
347
+ if weapon.dig(:bonus, :additional, :restriction) == 'nat20_attack' && attack_roll.nat_20?
348
+ damage_roll += Natural20::DieRoll.roll(weapon.dig(:bonus, :additional, :die),
349
+ description: t('dice_roll.special_weapon_damage'), entity: @source, battle: battle)
350
+ end
351
+
352
+ damage_roll
353
+ end
354
+ end
355
+
356
+ class TwoWeaponAttackAction < AttackAction
357
+ # @param entity [Natural20::Entity]
358
+ # @param battle [Natural20::Battle]
359
+ def self.can?(entity, battle, options = {})
360
+ battle.nil? || (entity.total_bonus_actions(battle).positive? && battle.two_weapon_attack?(entity) && options[:weapon] != battle.first_hand_weapon(entity) || entity.equipped_weapons.select do |a|
361
+ a == battle.first_hand_weapon(entity)
362
+ end.size >= 2)
363
+ end
364
+
365
+ def second_hand
366
+ true
367
+ end
368
+
369
+ def label
370
+ "Bonus Action -> #{super}"
371
+ end
372
+ end
@@ -0,0 +1,14 @@
1
+ module Natural20::ActionDamage
2
+ def damage_event(item, battle)
3
+ Natural20::EventManager.received_event({ source: item[:source], attack_roll: item[:attack_roll], target: item[:target], event: :attacked,
4
+ attack_name: item[:attack_name],
5
+ damage_type: item[:damage_type],
6
+ advantage_mod: item[:advantage_mod],
7
+ as_reaction: item[:as_reaction],
8
+ damage_roll: item[:damage],
9
+ sneak_attack: item[:sneak_attack],
10
+ adv_info: item[:adv_info],
11
+ value: item[:damage].result + (item[:sneak_attack]&.result.presence || 0) })
12
+ item[:target].take_damage!(item, battle)
13
+ end
14
+ end
@@ -0,0 +1,46 @@
1
+ # typed: true
2
+ class DashAction < Natural20::Action
3
+ attr_accessor :as_bonus_action
4
+
5
+ def build_map
6
+ OpenStruct.new({
7
+ param: nil,
8
+ next: -> { self }
9
+ })
10
+ end
11
+
12
+ def self.can?(entity, battle)
13
+ battle && entity.total_actions(battle).positive?
14
+ end
15
+
16
+ def resolve(_session, _map, opts = {})
17
+ @result = [{
18
+ source: @source,
19
+ type: :dash,
20
+ battle: opts[:battle]
21
+ }]
22
+ self
23
+ end
24
+
25
+ def apply!(battle)
26
+ @result.each do |item|
27
+ case (item[:type])
28
+ when :dash
29
+ Natural20::EventManager.received_event({ source: item[:source], event: :dash })
30
+ battle.entity_state_for(item[:source])[:movement] += item[:source].speed
31
+ end
32
+
33
+ if as_bonus_action
34
+ battle.entity_state_for(item[:source])[:bonus_action] -= 1
35
+ else
36
+ battle.entity_state_for(item[:source])[:action] -= 1
37
+ end
38
+ end
39
+ end
40
+ end
41
+
42
+ class DashBonusAction < DashAction
43
+ def self.can?(entity, battle)
44
+ battle && entity.class_feature?('cunning_action') && entity.total_bonus_actions(battle) > 0
45
+ end
46
+ end
@@ -0,0 +1,53 @@
1
+ # typed: true
2
+ class DisengageAction < Natural20::Action
3
+ attr_accessor :as_bonus_action
4
+
5
+ def self.can?(entity, battle)
6
+ battle && battle.combat? && entity.total_actions(battle).positive?
7
+ end
8
+
9
+ def build_map
10
+ OpenStruct.new({
11
+ param: nil,
12
+ next: -> { self }
13
+ })
14
+ end
15
+
16
+ def self.build(session, source)
17
+ action = DisengageAction.new(session, source, :attack)
18
+ action.build_map
19
+ end
20
+
21
+ def resolve(_session, _map, opts = {})
22
+ @result = [{
23
+ source: @source,
24
+ type: :disengage,
25
+ battle: opts[:battle]
26
+ }]
27
+ self
28
+ end
29
+
30
+ def apply!(battle)
31
+ @result.each do |item|
32
+ case (item[:type])
33
+ when :disengage
34
+ Natural20::EventManager.received_event({ source: item[:source], event: :disengage })
35
+ item[:source].disengage!(battle)
36
+ end
37
+
38
+ if as_bonus_action
39
+ battle.entity_state_for(item[:source])[:bonus_action] -= 1
40
+ else
41
+ battle.entity_state_for(item[:source])[:action] -= 1
42
+ end
43
+ end
44
+ end
45
+ end
46
+
47
+ class DisengageBonusAction < DisengageAction
48
+ # @param entity [Natural20::Entity]
49
+ # @param battle [Natural20::Battle]
50
+ def self.can?(entity, battle)
51
+ battle && battle.combat? && entity.any_class_feature?(%w[cunning_action nimble_escape]) && entity.total_bonus_actions(battle) > 0
52
+ end
53
+ end