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,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