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,168 @@
1
+ # typed: true
2
+ module Natural20
3
+ class Session
4
+ attr_reader :root_path, :game_properties, :game_time
5
+
6
+ # @param root_path [String] The current adventure working folder
7
+ # @return [Natural20::Session]
8
+ def self.new_session(root_path = nil)
9
+ @session = Natural20::Session.new(root_path)
10
+ @session
11
+ end
12
+
13
+ # @return [Natural20::Session]
14
+ def self.current_session
15
+ @session
16
+ end
17
+
18
+ def initialize(root_path = nil)
19
+ @root_path = root_path.presence || '.'
20
+ @session_state = {}
21
+ @weapons = {}
22
+ @equipment = {}
23
+ @objects = {}
24
+ @thing = {}
25
+ @char_classes = {}
26
+ @settings = {
27
+ manual_dice_roll: false
28
+ }
29
+ @game_time = 0 # game time in seconds
30
+
31
+ I18n.load_path << Dir[File.join(@root_path, 'locales') + '/*.yml']
32
+ I18n.default_locale = :en
33
+ if File.exist?(File.join(@root_path, 'game.yml'))
34
+ @game_properties = YAML.load_file(File.join(@root_path, 'game.yml')).deep_symbolize_keys!
35
+ else
36
+ raise t(:missing_game)
37
+ end
38
+ end
39
+
40
+ VALID_SETTINGS = %i[manual_dice_roll].freeze
41
+
42
+ # @options settings manual_dice_roll [Boolean]
43
+ def update_settings(settings = {})
44
+ settings.each_key { |k| raise 'invalid settings' unless VALID_SETTINGS.include?(k.to_sym) }
45
+
46
+ @settings.deep_merge!(settings.deep_symbolize_keys)
47
+ end
48
+
49
+ def setting(k)
50
+ raise 'invalid settings' unless VALID_SETTINGS.include?(k.to_sym)
51
+
52
+ @settings[k.to_sym]
53
+ end
54
+
55
+ def increment_game_time!(seconds = 6)
56
+ @game_time += seconds
57
+ end
58
+
59
+ def load_characters
60
+ files = Dir[File.join(@root_path, 'characters', '*.yml')]
61
+ @characters ||= files.map do |file|
62
+ YAML.load_file(file)
63
+ end
64
+ @characters.map do |char_content|
65
+ Natural20::PlayerCharacter.new(self, char_content)
66
+ end
67
+ end
68
+
69
+ # store a state
70
+ # @param state_type [String,Symbol]
71
+ # @param value [Hash]
72
+ def save_state(state_type, value = {})
73
+ @session_state[state_type.to_sym] ||= {}
74
+ @session_state[state_type.to_sym].deep_merge!(value)
75
+ end
76
+
77
+ def load_state(state_type)
78
+ @session_state[state_type.to_sym] || {}
79
+ end
80
+
81
+ def has_save_game?
82
+ File.exist?(File.join(@root_path, 'savegame.yml'))
83
+ end
84
+
85
+ # @param battle [Natural20::BattleMap]
86
+ def save_game(battle)
87
+ File.write(File.join(@root_path, 'savegame.yml'), battle.to_yaml)
88
+ end
89
+
90
+ def save_character(name, data)
91
+ File.write(File.join(@root_path, 'characters', "#{name}.yml"), data.to_yaml)
92
+ end
93
+
94
+ # @return [Natural20::Battle]
95
+ def load_save
96
+ YAML.load_file(File.join(@root_path, 'savegame.yml'))
97
+ end
98
+
99
+ def npc(npc_type, options = {})
100
+ Natural20::Npc.new(self, npc_type, options)
101
+ end
102
+
103
+ def load_npcs
104
+ files = Dir[File.join(@root_path, 'npcs', '*.yml')]
105
+ files.map do |fname|
106
+ npc_name = File.basename(fname, '.yml')
107
+ Natural20::Npc.new(self, npc_name, rand_life: true)
108
+ end
109
+ end
110
+
111
+ def load_races
112
+ files = Dir[File.join(@root_path, 'races', '*.yml')]
113
+ files.map do |fname|
114
+ race_name = File.basename(fname, '.yml')
115
+ [race_name, YAML.load_file(fname).deep_symbolize_keys!]
116
+ end.to_h
117
+ end
118
+
119
+ def load_classes
120
+ files = Dir[File.join(@root_path, 'char_classes', '*.yml')]
121
+ files.map do |fname|
122
+ class_name = File.basename(fname, '.yml')
123
+ [class_name, YAML.load_file(fname).deep_symbolize_keys!]
124
+ end.to_h
125
+ end
126
+
127
+ def load_class(klass)
128
+ @char_classes[klass.to_sym] ||= begin
129
+ YAML.load_file(File.join(@root_path, 'char_classes', "#{klass}.yml")).deep_symbolize_keys!
130
+ end
131
+ end
132
+
133
+ def load_weapon(weapon)
134
+ @weapons[weapon.to_sym] ||= begin
135
+ weapons = YAML.load_file(File.join(@root_path, 'items', 'weapons.yml')).deep_symbolize_keys!
136
+ weapons[weapon.to_sym]
137
+ end
138
+ end
139
+
140
+ def load_weapons
141
+ YAML.load_file(File.join(@root_path, 'items', 'weapons.yml')).deep_symbolize_keys!
142
+ end
143
+
144
+ def load_thing(item)
145
+ @thing[item.to_sym] ||= begin
146
+ load_weapon(item) || load_equipment(item) || load_object(item)
147
+ end
148
+ end
149
+
150
+ def load_equipment(item)
151
+ @equipment[item.to_sym] ||= begin
152
+ equipment = YAML.load_file(File.join(@root_path, 'items', 'equipment.yml')).deep_symbolize_keys!
153
+ equipment[item.to_sym]
154
+ end
155
+ end
156
+
157
+ def load_object(object_name)
158
+ @objects[object_name.to_sym] ||= begin
159
+ objects = YAML.load_file(File.join(@root_path, 'items', 'objects.yml')).deep_symbolize_keys!
160
+ objects[object_name.to_sym]
161
+ end
162
+ end
163
+
164
+ def t(token, options = {})
165
+ I18n.t(token, options)
166
+ end
167
+ end
168
+ end
@@ -0,0 +1,35 @@
1
+ module Natural20::Cover
2
+ # @param map [Natural20::BattleMap]
3
+ # @param source [Natural20::Entity]
4
+ # @param target [Natural20::Entity]
5
+ # @param entity_1_pos [Array<Integer,Integer>]
6
+ # @param entity_2_pos [Array<Integer,Integer>]
7
+ # @return [Integer]
8
+ def cover_calculation(map, source, target, entity_1_pos: nil, entity_2_pos: nil, naturally_stealthy: false)
9
+ source_squares = entity_1_pos ? map.entity_squares_at_pos(source, *entity_1_pos) : map.entity_squares(source)
10
+ target_squares = entity_2_pos ? map.entity_squares_at_pos(target, *entity_2_pos) : map.entity_squares(target)
11
+ source_position = map.position_of(source)
12
+ source_melee_square = source.melee_squares(map, target_position: source_position, adjacent_only: true)
13
+
14
+ source_squares.map do |source_pos|
15
+ target_squares.map do |target_pos|
16
+ cover_characteristics = map.line_of_sight?(*source_pos, *target_pos, nil, true, naturally_stealthy)
17
+ next 0 unless cover_characteristics
18
+
19
+ max_ac = 0
20
+ cover_characteristics.each do |cover|
21
+ cover_type, pos = cover
22
+
23
+ next if cover_type == :none
24
+ next if source_melee_square.include?(pos)
25
+
26
+ max_ac = [max_ac, 2].max if cover_type == :half
27
+ max_ac = [max_ac, 5].max if cover_type == :three_quarter
28
+
29
+ return 1 if cover_type.is_a?(Integer) && naturally_stealthy && (cover_type - target.size_identifier) >= 1
30
+ end
31
+ max_ac
32
+ end.min
33
+ end.min || 0
34
+ end
35
+ end
@@ -0,0 +1,90 @@
1
+ # typed: false
2
+ # class used for ray trace and path trace computations
3
+ class RayTracer
4
+ def initialize(map)
5
+ @map = map
6
+ end
7
+
8
+ def ray_trace(pos1_x, pos1_y, pos2_x, pos2_y, _max_distance)
9
+ return true if [pos1_x, pos1_y] == [pos2_x, pos2_y]
10
+
11
+ if pos2_x == pos1_x
12
+ scanner = pos2_y > pos1_y ? (pos1_y...pos2_y) : (pos2_y...pos1_y)
13
+
14
+ scanner.each_with_index do |y, index|
15
+ return false if !distance.nil? && index > distance
16
+ next if (y == pos1_y) || (y == pos2_y)
17
+ return false if @map.opaque?(pos1_x, y)
18
+ end
19
+ true
20
+ else
21
+ m = (pos2_y - pos1_y).to_f / (pos2_x - pos1_x)
22
+ scanner = pos2_x > pos1_x ? (pos1_x...pos2_x) : (pos2_x...pos1_x)
23
+ if m.zero?
24
+
25
+ scanner.each_with_index do |x, index|
26
+ return false if !distance.nil? && index > distance
27
+ next if (x == pos1_x) || (x == pos2_x)
28
+ return false if @map.opaque?(x, pos2_y)
29
+ end
30
+
31
+ true
32
+ else
33
+
34
+ b = pos1_y - m * pos1_x
35
+ step = m.abs > 1 ? 1 / m.abs : m.abs
36
+
37
+ scanner.step(step).each_with_index do |x, index|
38
+ y = (m * x + b).round
39
+
40
+ return false if !distance.nil? && index > distance
41
+ next if (x.round == pos1_x && y == pos1_y) || (x.round == pos2_x && y == pos2_y)
42
+ return false if @map.opaque?(x.round, y)
43
+ end
44
+ true
45
+ end
46
+ end
47
+ end
48
+
49
+ def line_of_sight?(pos1_x, pos1_y, pos2_x, pos2_y, distance = nil)
50
+ return true if [pos1_x, pos1_y] == [pos2_x, pos2_y]
51
+
52
+ if pos2_x == pos1_x
53
+ scanner = pos2_y > pos1_y ? (pos1_y...pos2_y) : (pos2_y...pos1_y)
54
+
55
+ scanner.each_with_index do |y, index|
56
+ return false if !distance.nil? && index > distance
57
+ next if (y == pos1_y) || (y == pos2_y)
58
+ return false if @map.opaque?(pos1_x, y)
59
+ end
60
+ true
61
+ else
62
+ m = (pos2_y - pos1_y).to_f / (pos2_x - pos1_x)
63
+ if m == 0
64
+ scanner = pos2_x > pos1_x ? (pos1_x...pos2_x) : (pos2_x...pos1_x)
65
+
66
+ scanner.each_with_index do |x, index|
67
+ return false if !distance.nil? && index > distance
68
+ next if (x == pos1_x) || (x == pos2_x)
69
+ return false if @map.opaque?(x, pos2_y)
70
+ end
71
+
72
+ true
73
+ else
74
+ scanner = pos2_x > pos1_x ? (pos1_x...pos2_x) : (pos2_x...pos1_x)
75
+
76
+ b = pos1_y - m * pos1_x
77
+ step = m.abs > 1 ? 1 / m.abs : m.abs
78
+
79
+ scanner.step(step).each_with_index do |x, index|
80
+ y = (m * x + b).round
81
+
82
+ return false if !distance.nil? && index > distance
83
+ next if (x.round == pos1_x && y == pos1_y) || (x.round == pos2_x && y == pos2_y)
84
+ return false if @map.opaque?(x.round, y)
85
+ end
86
+ true
87
+ end
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,72 @@
1
+ module Natural20
2
+ class StaticLightBuilder
3
+ attr_reader :lights
4
+
5
+ # @param battlemap [Natural20::BattleMap] location of the map yml file
6
+ def initialize(battlemap)
7
+ @map = battlemap
8
+ @properties = battlemap.properties
9
+ @light_properties = @properties[:lights]
10
+ @light_map = @properties.dig(:map, :light)
11
+ @base_illumniation = @properties.dig(:map, :illumination) || 1.0
12
+ @lights = []
13
+ if @light_map && @light_properties
14
+ @light_map.each_with_index do |row, cur_y|
15
+ row.each_char.map(&:to_sym).each_with_index do |key, cur_x|
16
+ next unless @light_properties[key]
17
+
18
+ @lights << {
19
+ position: [cur_x, cur_y]
20
+ }.merge(@light_properties[key])
21
+ end
22
+ end
23
+ end
24
+ end
25
+
26
+ def build_map
27
+ max_x, max_y = @map.size
28
+ max_y.times.map do |y|
29
+ max_x.times.map do |x|
30
+ @lights.inject(@base_illumniation) do |intensity, light|
31
+ light_pos_x, light_pos_y = light[:position]
32
+ bright_light = light.fetch(:bright, 10) / @map.feet_per_grid
33
+ dim_light = light.fetch(:dim, 5) / @map.feet_per_grid
34
+
35
+ intensity + if @map.line_of_sight?(x, y, light_pos_x, light_pos_y, bright_light, false)
36
+ 1.0
37
+ elsif @map.line_of_sight?(x, y, light_pos_x, light_pos_y, bright_light + dim_light, false)
38
+ 0.5
39
+ else
40
+ 0.0
41
+ end
42
+ end
43
+ end
44
+ end.transpose
45
+ end
46
+
47
+ # @param pos_x [Integer]
48
+ # @parma pos_y [Integer]
49
+ # @return [Float]
50
+ def light_at(pos_x, pos_y)
51
+ (@map.entities.keys + @map.interactable_objects.keys).inject(0.0) do |intensity, entity|
52
+ next intensity if entity.light_properties.nil?
53
+
54
+ light = entity.light_properties
55
+ bright_light = light.fetch(:bright, 0.0) / @map.feet_per_grid
56
+ dim_light = light.fetch(:dim, 0.0) / @map.feet_per_grid
57
+
58
+ next intensity if (bright_light + dim_light) <= 0.0
59
+
60
+ light_pos_x, light_pos_y = @map.entity_or_object_pos(entity)
61
+
62
+ intensity + if @map.line_of_sight?(pos_x, pos_y, light_pos_x, light_pos_y, bright_light, false)
63
+ 1.0
64
+ elsif @map.line_of_sight?(pos_x, pos_y, light_pos_x, light_pos_y, bright_light + dim_light, false)
65
+ 0.5
66
+ else
67
+ 0.0
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,78 @@
1
+ # reusable utility methods for weapon calculations
2
+ module Natural20::Weapons
3
+ # Check all the factors that affect advantage/disadvantage in attack rolls
4
+ def target_advantage_condition(battle, source, target, weapon, source_pos: nil)
5
+ advantages, disadvantages = compute_advantages_and_disadvantages(battle, source, target, weapon,
6
+ source_pos: source_pos)
7
+
8
+ return [0, [advantages, disadvantages]] if advantages.empty? && disadvantages.empty?
9
+ return [0, [advantages, disadvantages]] if !advantages.empty? && !disadvantages.empty?
10
+
11
+ return [1, [advantages, disadvantages]] unless advantages.empty?
12
+
13
+ [-1, [advantages, disadvantages]]
14
+ end
15
+
16
+ # Compute all advantages and disadvantages
17
+ # @param battle [Natural20::Battle]
18
+ # @param source [Natural20::Entity]
19
+ # @param target [Natural20::Entity]
20
+ # @option weapon type [String]
21
+ # @return [Array]
22
+ def compute_advantages_and_disadvantages(battle, source, target, weapon, source_pos: nil)
23
+ weapon = battle.session.load_weapon(weapon) if weapon.is_a?(String) || weapon.is_a?(Symbol)
24
+ advantage = []
25
+ disadvantage = []
26
+
27
+ disadvantage << :prone if source.prone?
28
+ disadvantage << :squeezed if source.squeezed?
29
+ disadvantage << :target_dodge if target.dodge?(battle)
30
+ disadvantage << :armor_proficiency unless source.proficient_with_equipped_armor?
31
+ advantage << :squeezed if target.squeezed?
32
+ advantage << :being_helped if battle.help_with?(target)
33
+ disadvantage << :target_long_range if battle.map && battle.map.distance(source, target,
34
+ entity_1_pos: source_pos) > weapon[:range]
35
+
36
+ if weapon[:type] == 'ranged_attack' && battle.map
37
+ disadvantage << :ranged_with_enemy_in_melee if battle.enemy_in_melee_range?(source, source_pos: source_pos)
38
+ disadvantage << :target_is_prone_range if target.prone?
39
+ end
40
+
41
+ if source.class_feature?('pack_tactics') && battle.ally_within_enemey_melee_range?(source, target,
42
+ source_pos: source_pos)
43
+ advantage << :pack_tactics
44
+ end
45
+
46
+ disadvantage << :small_creature_using_heavy if weapon[:properties]&.include?('heavy') && source.size == :small
47
+ advantage << :target_is_prone if weapon[:type] == 'melee_attack' && target.prone?
48
+
49
+ advantage << :unseen_attacker if battle.map && !battle.can_see?(target, source, entity_2_pos: source_pos)
50
+ disadvantage << :invisible_attacker if battle.map && !battle.can_see?(source, target, entity_1_pos: source_pos)
51
+ [advantage, disadvantage]
52
+ end
53
+
54
+ # Calculates weapon damage roll
55
+ # @param entity [Natural20::Entity]
56
+ # @param weapon [Hash] weapon descriptor
57
+ # @param second_hand [Boolean] Second hand to be used for two weapon fighting
58
+ # @return [String]
59
+ def damage_modifier(entity, weapon, second_hand: false)
60
+ damage_mod = entity.attack_ability_mod(weapon)
61
+
62
+ damage_mod = [damage_mod, 0].min if second_hand && !entity.class_feature?('two_weapon_fighting')
63
+
64
+ # compute damage roll using versatile weapon property
65
+ damage_roll = if weapon[:properties]&.include?('versatile') && entity.used_hand_slots <= 1.0
66
+ weapon[:damage_2]
67
+ else
68
+ weapon[:damage]
69
+ end
70
+
71
+ # duelist class feature
72
+ if entity.class_feature?('dueling') && weapon[:type] == 'melee_attack' && entity.used_hand_slots(weapon_only: true) <= 1.0
73
+ damage_mod += 2
74
+ end
75
+
76
+ "#{damage_roll}+#{damage_mod}"
77
+ end
78
+ end