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,136 @@
1
+ module Natural20::InventoryUI
2
+ include Natural20::Weapons
3
+
4
+ def character_sheet(entity)
5
+ puts t('character_sheet.name', name: entity.name)
6
+ puts t('character_sheet.level', level: entity.level)
7
+ puts t('character_sheet.class', name: entity.class_properties.keys.join(','))
8
+ puts ' str dex con int wis cha'
9
+ puts ' ---- ---- ---- ---- ---- ---- '
10
+ puts "|#{entity.all_ability_scores.map { |s| " #{s} " }.join('||')}|"
11
+ puts "|#{entity.all_ability_mods.map { |s| " #{s.negative? ? s : "+#{s}"} " }.join('||')}|"
12
+ puts ' ---- ---- ---- ---- ---- ----'
13
+ puts t('character_sheet.race', race: entity.race.humanize)
14
+ puts t('character_sheet.subrace', race: entity.subrace.to_s.humanize) if entity.subrace
15
+ puts t('character_sheet.hp', current: entity.hp, max: entity.max_hp)
16
+ puts t('character_sheet.ac', ac: entity.armor_class)
17
+ puts t('character_sheet.speed', speed: entity.speed)
18
+ puts t('character_sheet.languages')
19
+ entity.languages.each do |lang|
20
+ puts " #{t("language.#{lang}")}"
21
+ end
22
+ puts t('character_sheet.skills')
23
+ Natural20::Entity::ALL_SKILLS.each do |skill|
24
+ bonus_mod = entity.send("#{skill}_mod")
25
+ prefix = entity.proficient?(skill) ? '*' : ' '
26
+ puts " #{t('character_sheet.skill_mod', prefix: prefix, skill: skill.to_s.ljust(20, ' '),
27
+ bonus: bonus_mod.negative? ? bonus_mod : "+#{bonus_mod}")}"
28
+ end
29
+ unless entity.expertise.blank?
30
+ puts t('character_sheet.expertise')
31
+ entity.expertise.each do |prof|
32
+ puts " #{prof.humanize}"
33
+ end
34
+ end
35
+ end
36
+
37
+ # @param entity [Natural20::Entity]
38
+ def inventory_ui(entity)
39
+ begin
40
+ character_sheet(entity)
41
+ item_inventory_choice = prompt.select(
42
+ t('character_sheet.inventory', weight: entity.inventory_weight,
43
+ carry_capacity: entity.carry_capacity), per_page: 20
44
+ ) do |menu|
45
+ entity.equipped_items.each do |m|
46
+ proficient_str = ''
47
+ if m.subtype == 'weapon' && !entity.proficient_with_weapon?(m)
48
+ proficient_str = '(not proficient)'.colorize(:red)
49
+ end
50
+ proficient_str = '(not proficient)'.colorize(:red) if %w[armor
51
+ shield].include?(m.type) && !entity.proficient_with_armor?(m.name)
52
+ menu.choice "#{m.label} (equipped) #{proficient_str}", m
53
+ end
54
+ entity.inventory.each do |m|
55
+ menu.choice "#{m.label} x #{m.qty}", m
56
+ end
57
+ menu.choice 'Back', :back
58
+ end
59
+
60
+ return nil if item_inventory_choice == :back
61
+
62
+ weapon_details = session.load_weapon(item_inventory_choice.name)
63
+ if weapon_details
64
+ puts ' '
65
+ puts '-------'
66
+ puts (weapon_details[:label] || weapon_details[:name]).colorize(:blue)
67
+ puts "Damage Stats: to Hit +#{entity.attack_roll_mod(weapon_details)}. #{damage_modifier(entity,
68
+ weapon_details)} #{weapon_details[:damage_type]} Damage"
69
+ puts "Proficiency: #{entity.proficient_with_weapon?(weapon_details) ? t(:yes) : t(:no)}"
70
+ puts 'Properties:'
71
+ weapon_details[:properties]&.each do |p|
72
+ puts " #{t("object.properties.#{p}")}"
73
+ end
74
+ puts weapon_details[:description] || ''
75
+ end
76
+
77
+ if item_inventory_choice.equipped
78
+ choice = prompt.select(item_inventory_choice.label) do |menu|
79
+ menu.choice t(:unequip), :unequip
80
+ menu.choice t(:back), :back
81
+ end
82
+
83
+ next if choice == :back
84
+
85
+ if battle.combat? && weapon_details[:type] == 'armor'
86
+ puts t('inventory.cannot_change_armor_combat')
87
+ next
88
+ end
89
+ entity.unequip(item_inventory_choice.name)
90
+ else
91
+ choice = prompt.select(item_inventory_choice.label) do |menu|
92
+ reasons = entity.check_equip(item_inventory_choice.name)
93
+ if reasons == :ok || reasons != :unequippable
94
+ if reasons == :ok
95
+ menu.choice 'Equip', :equip
96
+ else
97
+ menu.choice 'Equip', :equip, disabled: t("object.equip_problem.#{reasons}")
98
+ end
99
+ end
100
+ menu.choice t(:drop), :drop if map.ground_at(*map.entity_or_object_pos(entity))
101
+ menu.choice 'Back', :back
102
+ end
103
+
104
+ case choice
105
+ when :back
106
+ next
107
+ when :drop # drop item to the ground
108
+ qty = how_many?(item_inventory_choice)
109
+ entity.drop_items!(battle, [[item_inventory_choice, qty]])
110
+ when :equip
111
+ if battle.combat? && weapon_details[:type] == 'armor'
112
+ puts t('inventory.cannot_change_armor_combat')
113
+ next
114
+ end
115
+ entity.equip(item_inventory_choice.name)
116
+ end
117
+ end
118
+ end while true
119
+ end
120
+
121
+ def how_many?(item_inventory_choice)
122
+ if item_inventory_choice.qty > 1
123
+ item_count = item_inventory_choice.qty
124
+ m = item_inventory_choice
125
+ item_label = t("object.#{m.label}", default: m.label)
126
+ count = prompt.ask(t('inventory.how_many', item_label: item_label, item_count: item_count),
127
+ default: item_count) do |q|
128
+ q.in "1-#{item_count}"
129
+ q.messages[:range?] = '%{value} out of expected range %{in}'
130
+ end
131
+ count.to_i
132
+ else
133
+ 1
134
+ end
135
+ end
136
+ end
@@ -0,0 +1,165 @@
1
+ module Natural20
2
+ class MapRenderer
3
+ DEFAULT_TOKEN_COLOR = :cyan
4
+ attr_reader :map, :battle
5
+
6
+ # @param map [Natural20::BattleMap]
7
+ def initialize(map, battle = nil)
8
+ @map = map
9
+ @battle = battle
10
+ end
11
+
12
+ # Renders the current map to a string
13
+ # @param line_of_sight [Array,Natural20::Entity] Entity or entities to consier line of sight rendering for
14
+ # @param path [Array] Array of path to render movement
15
+ # @param select_pos [Array] coordinate position to render selection cursor
16
+ # @param update_on_drop [Boolean] If true, only render line of sight if movement has been confirmed
17
+ # @return [String]
18
+ def render(entity: nil, line_of_sight: nil, path: [], acrobatics_checks: [], athletics_checks: [], select_pos: nil, update_on_drop: true, path_char: nil, highlight: {})
19
+ highlight_positions = highlight.keys.map { |entity| @map.entity_squares(entity) }.flatten(1)
20
+
21
+ base_map.transpose.each_with_index.map do |row, row_index|
22
+ row.each_with_index.map do |c, col_index|
23
+ display = render_position(c, col_index, row_index, path: path, override_path_char: path_char, entity: entity, line_of_sight: line_of_sight,
24
+ update_on_drop: update_on_drop, acrobatics_checks: acrobatics_checks,
25
+ athletics_checks: athletics_checks)
26
+
27
+ display = display.colorize(background: :red) if highlight_positions.include?([col_index, row_index])
28
+
29
+ if select_pos && select_pos == [col_index, row_index]
30
+ display.colorize(background: :white)
31
+ else
32
+ display
33
+ end
34
+ end.join
35
+ end.join("\n") + "\n"
36
+ end
37
+
38
+ private
39
+
40
+ def render_light(pos_x, pos_y)
41
+ intensity = @map.light_at(pos_x, pos_y)
42
+ if intensity >= 1.0
43
+ :light_yellow
44
+ elsif intensity >= 0.5
45
+ :light_black
46
+ else
47
+ :black
48
+ end
49
+ end
50
+
51
+ def object_token(pos_x, pos_y)
52
+ object_meta = @map.object_at(pos_x, pos_y)
53
+ return nil unless object_meta
54
+
55
+ m_x, m_y = @map.interactable_objects[object_meta]
56
+ color = (object_meta.color.presence || DEFAULT_TOKEN_COLOR).to_sym
57
+
58
+ return nil unless object_meta.token
59
+
60
+ if object_meta.token.is_a?(Array)
61
+ object_meta.token[pos_y - m_y][pos_x - m_x].colorize(color)
62
+ else
63
+ object_meta.token.colorize(color)
64
+ end
65
+ end
66
+
67
+ def npc_token(pos_x, pos_y)
68
+ entity = tokens[pos_x][pos_y]
69
+ color = (entity[:entity]&.color.presence || DEFAULT_TOKEN_COLOR).to_sym
70
+ token(entity, pos_x, pos_y).colorize(color)
71
+ end
72
+
73
+ def render_position(c, col_index, row_index, path: [], override_path_char: nil, entity: nil, line_of_sight: nil, update_on_drop: true, acrobatics_checks: [],
74
+ athletics_checks: [])
75
+ background_color = render_light(col_index, row_index)
76
+ default_ground = "\u00B7".encode('utf-8').colorize(color: DEFAULT_TOKEN_COLOR, background: background_color)
77
+ c = case c
78
+ when '.', '?'
79
+ default_ground
80
+ else
81
+ object_token(col_index, row_index)&.colorize(background: background_color) || default_ground
82
+ end
83
+
84
+ # render map layer
85
+ token = if tokens[col_index][row_index]&.fetch(:entity)&.dead?
86
+ '`'.colorize(color: DEFAULT_TOKEN_COLOR)
87
+ elsif tokens[col_index][row_index]
88
+ if any_line_of_sight?(line_of_sight, tokens[col_index][row_index][:entity])
89
+ npc_token(col_index,
90
+ row_index)
91
+ end
92
+ end
93
+
94
+ token = (token&.colorize(background: background_color) || c)
95
+
96
+ if !path.empty? && (override_path_char.nil? || path[0] != [col_index, row_index])
97
+ if path.include?([col_index, row_index])
98
+ path_char = override_path_char || token
99
+ path_color = entity && map.jump_required?(entity, col_index, row_index) ? :red : :blue
100
+ path_char = "\u2713" if athletics_checks.include?([col_index,
101
+ row_index]) || acrobatics_checks.include?([col_index,
102
+ row_index])
103
+
104
+ colored_path = path_char.colorize(color: path_color, background: :white)
105
+ colored_path = colored_path.blink if path[0] == [col_index, row_index]
106
+ return colored_path
107
+ end
108
+ return ' '.colorize(background: :black) if line_of_sight && !location_is_visible?(update_on_drop, col_index, row_index,
109
+ path)
110
+ else
111
+ has_line_of_sight = if line_of_sight.is_a?(Array)
112
+ line_of_sight.detect { |l| @map.can_see_square?(l, col_index, row_index) }
113
+ elsif line_of_sight
114
+ @map.can_see_square?(line_of_sight, col_index, row_index)
115
+ end
116
+ return ' '.colorize(background: :black) if line_of_sight && !has_line_of_sight
117
+ end
118
+
119
+ token
120
+ end
121
+
122
+ def location_is_visible?(update_on_drop, pos_x, pos_y, path)
123
+ return @map.line_of_sight?(path.last[0], path.last[1], col_index, row_index, nil, false) unless update_on_drop
124
+
125
+ @map.line_of_sight?(path.last[0], path.last[1], pos_x, pos_y,
126
+ 1, false) || @map.line_of_sight?(path.first[0], path.first[1], pos_x, pos_y, nil, false)
127
+ end
128
+
129
+ def any_line_of_sight?(line_of_sight, entity)
130
+ return true if @battle.nil?
131
+
132
+ if line_of_sight.is_a?(Array)
133
+ line_of_sight.detect do |l|
134
+ @battle.can_see?(l,
135
+ entity)
136
+ end
137
+ else
138
+ @battle.can_see?(line_of_sight,
139
+ entity)
140
+ end
141
+ end
142
+
143
+ def base_map
144
+ @map.base_map
145
+ end
146
+
147
+ # Returns the character to use as token for a square
148
+ # @param entity [Natural20::Entity]
149
+ # @param pos_x [Integer]
150
+ # @param pos_y [Integer]
151
+ # @return [String]
152
+ def token(entity, pos_x, pos_y)
153
+ if entity[:entity].token
154
+ m_x, m_y = @map.entities[entity[:entity]]
155
+ entity[:entity].token[pos_y - m_y][pos_x - m_x]
156
+ else
157
+ @map.tokens[pos_x][pos_y][:token]
158
+ end
159
+ end
160
+
161
+ def tokens
162
+ @map.tokens
163
+ end
164
+ end
165
+ end
@@ -0,0 +1,32 @@
1
+ module Natural20::Container
2
+ # @param battle [Natural20::Battle]
3
+ # @param source [Natural20::Entity]
4
+ # @param target [Natural20::Entity]
5
+ # @param items [Array]
6
+ def store(battle, source, target, items)
7
+ items.each do |item_with_count|
8
+ item, qty = item_with_count
9
+ source_item = source.deduct_item(item.name, qty)
10
+ target.add_item(item.name, qty, source_item)
11
+ battle.trigger_event!(:object_received, target, item_type: item.name)
12
+ end
13
+ end
14
+
15
+ # @param battle [Natural20::Battle]
16
+ # @param source [Natural20::Entity]
17
+ # @param target [Natural20::Entity]
18
+ # @param items [Array]
19
+ def retrieve(battle, source, target, items)
20
+ items.each do |item_with_count|
21
+ item, qty = item_with_count
22
+ if item.equipped
23
+ unequip(item.name, transfer_inventory: false)
24
+ source.add_item(item.name)
25
+ else
26
+ source_item = target.deduct_item(item.name, qty)
27
+ source.add_item(item.name, qty, source_item)
28
+ battle.trigger_event!(:object_received, source, item_type: item.name)
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,1213 @@
1
+ # typed: false
2
+ module Natural20
3
+ module Entity
4
+ include EntityStateEvaluator
5
+
6
+ attr_accessor :entity_uid, :statuses, :color, :session, :death_saves,
7
+ :death_fails, :current_hit_die, :max_hit_die
8
+
9
+ ATTRIBUTE_TYPES = %w[strength dexterity constitution intelligence wisdom charisma]
10
+ ATTRIBUTE_TYPES_ABBV = %w[str dex con int wis cha]
11
+ def label
12
+ I18n.exists?(name, :en) ? I18n.t(name) : name.humanize
13
+ end
14
+
15
+ def race
16
+ @properties[:race]
17
+ end
18
+
19
+ def all_ability_scores
20
+ %i[str dex con int wis cha].map do |att|
21
+ @ability_scores[att]
22
+ end
23
+ end
24
+
25
+ def all_ability_mods
26
+ %i[str dex con int wis cha].map do |att|
27
+ modifier_table(@ability_scores.fetch(att))
28
+ end
29
+ end
30
+
31
+ def expertise?(prof)
32
+ @properties[:expertise]&.include?(prof.to_s)
33
+ end
34
+
35
+ def heal!(amt)
36
+ return if dead?
37
+
38
+ prev_hp = @hp
39
+ @death_saves = 0
40
+ @death_fails = 0
41
+ @hp = [max_hp, @hp + amt].min
42
+
43
+ conscious!
44
+ Natural20::EventManager.received_event({ source: self, event: :heal, previous: prev_hp, new: @hp, value: amt })
45
+ end
46
+
47
+ # @option damage_params damage [Natural20::DieRoll]
48
+ # @option damage_params sneak_attack [Natural20::DieRoll]
49
+ # @param battle [Natural20::Battle]
50
+ def take_damage!(damage_params, battle = nil)
51
+ dmg = damage_params[:damage].is_a?(Natural20::DieRoll) ? damage_params[:damage].result : damage_params[:damage]
52
+ dmg += damage_params[:sneak_attack].result unless damage_params[:sneak_attack].nil?
53
+
54
+ dmg = (dmg / 2.to_f).floor if resistant_to?(damage_params[:damage_type])
55
+ @hp -= dmg
56
+
57
+ if unconscious?
58
+ @statuses.delete(:stable)
59
+ @death_fails += if damage_params[:attack_roll].nat_20?
60
+ 2
61
+ else
62
+ 1
63
+ end
64
+
65
+ complete = false
66
+ if @death_fails >= 3
67
+ complete = true
68
+ dead!
69
+ @death_saves = 0
70
+ @death_fails = 0
71
+ end
72
+ Natural20::EventManager.received_event({ source: self, event: :death_fail, saves: @death_saves,
73
+ fails: @death_fails, complete: complete })
74
+ end
75
+
76
+ if @hp.negative? && @hp.abs >= @properties[:max_hp]
77
+ dead!
78
+ elsif @hp <= 0
79
+ npc? ? dead! : unconscious!
80
+ end
81
+
82
+ @hp = 0 if @hp <= 0
83
+
84
+ on_take_damage(battle, damage_params) if battle
85
+
86
+ Natural20::EventManager.received_event({ source: self, event: :damage, value: dmg })
87
+ end
88
+
89
+ def resistant_to?(damage_type)
90
+ @resistances.include?(damage_type)
91
+ end
92
+
93
+ def dead!
94
+ unless dead?
95
+ Natural20::EventManager.received_event({ source: self, event: :died })
96
+ drop_grapple!
97
+ @statuses.add(:dead)
98
+ @statuses.delete(:stable)
99
+ @statuses.delete(:unconscious)
100
+ end
101
+ end
102
+
103
+ def prone!
104
+ Natural20::EventManager.received_event({ source: self, event: :prone })
105
+ @statuses.add(:prone)
106
+ end
107
+
108
+ def stand!
109
+ Natural20::EventManager.received_event({ source: self, event: :stand })
110
+ @statuses.delete(:prone)
111
+ end
112
+
113
+ def prone?
114
+ @statuses.include?(:prone)
115
+ end
116
+
117
+ def dead?
118
+ @statuses.include?(:dead)
119
+ end
120
+
121
+ def unconscious?
122
+ !dead? && @statuses.include?(:unconscious)
123
+ end
124
+
125
+ def conscious?
126
+ !dead? && !unconscious?
127
+ end
128
+
129
+ def stable?
130
+ @statuses.include?(:stable)
131
+ end
132
+
133
+ def object?
134
+ false
135
+ end
136
+
137
+ def npc?
138
+ false
139
+ end
140
+
141
+ def pc?
142
+ false
143
+ end
144
+
145
+ def sentient?
146
+ npc? || pc?
147
+ end
148
+
149
+ def conscious!
150
+ @statuses.delete(:unconscious)
151
+ @statuses.delete(:stable)
152
+ end
153
+
154
+ def stable!
155
+ @statuses.add(:stable)
156
+ @death_fails = 0
157
+ @death_saves = 0
158
+ end
159
+
160
+ # convenience method used to determine if a creature
161
+ # entered or is at melee range of another
162
+ def entered_melee?(map, entity, pos_x, pos_y)
163
+ entity_1_sq = map.entity_squares(self)
164
+ entity_2_sq = map.entity_squares_at_pos(entity, pos_x, pos_y)
165
+
166
+ entity_1_sq.each do |entity_1_pos|
167
+ entity_2_sq.each do |entity_2_pos|
168
+ cur_x, cur_y = entity_1_pos
169
+ pos_x, pos_y = entity_2_pos
170
+
171
+ distance = Math.sqrt((cur_x - pos_x)**2 + (cur_y - pos_y)**2).floor * map.feet_per_grid # one square - 5 ft
172
+
173
+ # determine melee options
174
+ return true if distance <= melee_distance
175
+ end
176
+ end
177
+
178
+ false
179
+ end
180
+
181
+ def melee_distance
182
+ 0
183
+ end
184
+
185
+ # @param map [Natural20::BattleMap]
186
+ def push_from(map, pos_x, pos_y, distance = 5)
187
+ x, y = map.entity_or_object_pos(self)
188
+ effective_token_size = token_size - 1
189
+ ofs_x, ofs_y = if pos_x.between?(x, x + effective_token_size) && !pos_y.between?(y, y + effective_token_size)
190
+ [0, y - pos_y > 0 ? distance : -distance]
191
+ elsif pos_y.between?(y, y + effective_token_size) && !pos_x.between?(x, x + effective_token_size)
192
+ [x - pos_x > 0 ? distance : -distance, 0]
193
+ elsif [pos_x, pos_y] == [x - 1, y - 1]
194
+ [distance, distance]
195
+ elsif [pos_x, pos_y] == [x + effective_token_size + 1, y - 1]
196
+ [-distance, distance]
197
+ elsif [pos_x, pos_y] == [x - 1, y + effective_token_size + 1]
198
+ [distance, -distance]
199
+ elsif [pos_x, pos_y] == [x + effective_token_size + 1, y + effective_token_size + 1]
200
+ [-disance, -distance]
201
+ else
202
+ raise "invalid source position #{pos_x}, #{pos_y}"
203
+ end
204
+ # convert to squares
205
+ ofs_x /= map.feet_per_grid
206
+ ofs_y /= map.feet_per_grid
207
+
208
+ if map.placeable?(self, x + ofs_x, y + ofs_y)
209
+ [x + ofs_x, y + ofs_y]
210
+ else
211
+ nil
212
+ end
213
+ end
214
+
215
+ # @param map [Natural20::BattleMap]
216
+ # @param target_position [Array<Integer,Integer>]
217
+ # @param adjacent_only [Boolean] If false uses melee distance otherwise uses fixed 1 square away
218
+ def melee_squares(map, target_position: nil, adjacent_only: false)
219
+ result = []
220
+ step = adjacent_only ? 1 : melee_distance / map.feet_per_grid
221
+ cur_x, cur_y = target_position || map.entity_or_object_pos(self)
222
+ (-step..step).each do |x_off|
223
+ (-step..step).each do |y_off|
224
+ next if x_off.zero? && y_off.zero?
225
+
226
+ # adjust melee position based on token size
227
+ adjusted_x_off = x_off
228
+ adjusted_y_off = y_off
229
+
230
+ adjusted_x_off -= token_size - 1 if x_off.negative?
231
+ adjusted_y_off -= token_size - 1 if y_off.negative?
232
+
233
+ position = [cur_x + adjusted_x_off, cur_y + adjusted_y_off]
234
+
235
+ if position[0].negative? || position[0] >= map.size[0] || position[1].negative? || position[1] >= map.size[1]
236
+ next
237
+ end
238
+
239
+ result << position
240
+ end
241
+ end
242
+ result
243
+ end
244
+
245
+ def locate_melee_positions(map, target_position, battle = nil)
246
+ result = []
247
+ step = melee_distance / map.feet_per_grid
248
+ cur_x, cur_y = target_position
249
+ (-step..step).each do |x_off|
250
+ (-step..step).each do |y_off|
251
+ next if x_off.zero? && y_off.zero?
252
+
253
+ # adjust melee position based on token size
254
+ adjusted_x_off = x_off
255
+ adjusted_y_off = y_off
256
+
257
+ adjusted_x_off -= token_size - 1 if x_off < 0
258
+ adjusted_y_off -= token_size - 1 if y_off < 0
259
+
260
+ position = [cur_x + adjusted_x_off, cur_y + adjusted_y_off]
261
+
262
+ if position[0].negative? || position[0] >= map.size[0] || position[1].negative? || position[1] >= map.size[1]
263
+ next
264
+ end
265
+ next unless map.placeable?(self, *position, battle)
266
+
267
+ result << position
268
+ end
269
+ end
270
+ result
271
+ end
272
+
273
+ def languages
274
+ @properties[:languages] || []
275
+ end
276
+
277
+ def darkvision?(distance)
278
+ return false unless @properties[:darkvision]
279
+ return false if @properties[:darkvision] < distance
280
+
281
+ true
282
+ end
283
+
284
+ def unconscious!
285
+ return if unconscious? || dead?
286
+
287
+ drop_grapple!
288
+ Natural20::EventManager.received_event({ source: self, event: :unconscious })
289
+ @statuses.add(:unconscious)
290
+ end
291
+
292
+ def description
293
+ @properties[:description].presence || ''
294
+ end
295
+
296
+ def initiative!(battle = nil)
297
+ roll = Natural20::DieRoll.roll("1d20+#{dex_mod}", description: t('dice_roll.initiative'), entity: self,
298
+ battle: battle)
299
+ value = roll.result.to_f + @ability_scores.fetch(:dex) / 100.to_f
300
+ Natural20::EventManager.received_event({ source: self, event: :initiative, roll: roll, value: value })
301
+ value
302
+ end
303
+
304
+ # Perform a death saving throw
305
+ def death_saving_throw!(battle = nil)
306
+ roll = Natural20::DieRoll.roll('1d20', description: t('dice_roll.death_saving_throw'), entity: self,
307
+ battle: battle)
308
+ if roll.nat_20?
309
+ conscious!
310
+ heal!(1)
311
+
312
+ Natural20::EventManager.received_event({ source: self, event: :death_save, roll: roll, saves: @death_saves,
313
+ fails: death_fails, complete: true, stable: true, success: true })
314
+ elsif roll.result >= 10
315
+ @death_saves += 1
316
+ complete = false
317
+
318
+ if @death_saves >= 3
319
+ complete = true
320
+ @death_saves = 0
321
+ @death_fails = 0
322
+ stable!
323
+ end
324
+ Natural20::EventManager.received_event({ source: self, event: :death_save, roll: roll, saves: @death_saves,
325
+ fails: @death_fails, complete: complete, stable: complete })
326
+ else
327
+ @death_fails += roll.nat_1? ? 2 : 1
328
+ complete = false
329
+ if @death_fails >= 3
330
+ complete = true
331
+ dead!
332
+ @death_saves = 0
333
+ @death_fails = 0
334
+ end
335
+
336
+ Natural20::EventManager.received_event({ source: self, event: :death_fail, roll: roll, saves: @death_saves,
337
+ fails: @death_fails, complete: complete })
338
+ end
339
+ end
340
+
341
+ # @param battle [Natural20::Battle]
342
+ # @return [Hash]
343
+ def reset_turn!(battle)
344
+ entity_state = battle.entity_state_for(self)
345
+ entity_state.merge!({
346
+ action: 1,
347
+ bonus_action: 1,
348
+ reaction: 1,
349
+ movement: speed,
350
+ free_object_interaction: 1,
351
+ active_perception: 0,
352
+ active_perception_disadvantage: 0,
353
+ two_weapon: nil
354
+ })
355
+ entity_state[:statuses].delete(:dodge)
356
+ entity_state[:statuses].delete(:disengage)
357
+ battle.dismiss_help_actions_for(self)
358
+
359
+ entity_state
360
+ end
361
+
362
+ # @param grappler [Natural20::Entity]
363
+ def grappled_by!(grappler)
364
+ @statuses.add(:grappled)
365
+ @grapples ||= []
366
+ @grapples << grappler
367
+ grappler.grappling(self)
368
+ end
369
+
370
+ def escape_grapple_from!(grappler)
371
+ @grapples ||= []
372
+ @grapples.delete(grappler)
373
+ @statuses.delete(:grappled) if @grapples.empty?
374
+ grappler.ungrapple(self)
375
+ end
376
+
377
+ def grapples
378
+ @grapples || []
379
+ end
380
+
381
+ def dodging!(battle)
382
+ entity_state = battle.entity_state_for(self)
383
+ entity_state[:statuses].add(:dodge)
384
+ end
385
+
386
+ # @param battle [Natural20::Battle]
387
+ # @param stealth [Integer]
388
+ def hiding!(battle, stealth)
389
+ entity_state = battle.entity_state_for(self)
390
+ entity_state[:statuses].add(:hiding)
391
+ entity_state[:stealth] = stealth
392
+ end
393
+
394
+ def break_stealth!(battle)
395
+ entity_state = battle.entity_state_for(self)
396
+ entity_state[:statuses].delete(:hiding)
397
+ entity_state[:stealth] = 0
398
+ end
399
+
400
+ def disengage!(battle)
401
+ entity_state = battle.entity_state_for(self)
402
+ entity_state[:statuses].add(:disengage)
403
+ end
404
+
405
+ def disengage?(battle)
406
+ entity_state = battle.entity_state_for(self)
407
+ entity_state[:statuses]&.include?(:disengage)
408
+ end
409
+
410
+ def dodge?(battle)
411
+ entity_state = battle.entity_state_for(self)
412
+ return false unless entity_state
413
+
414
+ entity_state[:statuses]&.include?(:dodge)
415
+ end
416
+
417
+ def hiding?(battle)
418
+ entity_state = battle.entity_state_for(self)
419
+ return false unless entity_state
420
+
421
+ entity_state[:statuses]&.include?(:hiding)
422
+ end
423
+
424
+ def help?(battle, target)
425
+ entity_state = battle.entity_state_for(target)
426
+ return entity_state[:target_effect][self] == :help if entity_state[:target_effect]&.key?(self)
427
+
428
+ false
429
+ end
430
+
431
+ # check if current entity can see target at a certain location
432
+ def can_see?(cur_pos_x, cur_pos_y, _target_entity, pos_x, pos_y, battle)
433
+ battle.map.line_of_sight?(cur_pos_x, cur_pos_y, pos_x, pos_y)
434
+
435
+ # TODO, check invisiblity etc, range
436
+ true
437
+ end
438
+
439
+ def help!(battle, target)
440
+ target_state = battle.entity_state_for(target)
441
+ target_state[:target_effect][self] = if battle.opposing?(self, target)
442
+ :help
443
+ else
444
+ :help_ability_check
445
+ end
446
+ end
447
+
448
+ # Checks if an entity still has an action available
449
+ # @param battle [Natural20::Battle]
450
+ def action?(battle = nil)
451
+ return true if battle.nil?
452
+
453
+ (battle.entity_state_for(self)[:action].presence || 0).positive?
454
+ end
455
+
456
+ def total_actions(battle)
457
+ battle.entity_state_for(self)[:action]
458
+ end
459
+
460
+ def total_reactions(battle)
461
+ battle.entity_state_for(self)[:reaction]
462
+ end
463
+
464
+ def free_object_interaction?(battle)
465
+ return true unless battle
466
+
467
+ (battle.entity_state_for(self)[:free_object_interaction].presence || 0).positive?
468
+ end
469
+
470
+ def total_bonus_actions(battle)
471
+ battle.entity_state_for(self)[:bonus_action]
472
+ end
473
+
474
+ # @param battle [Natural::20]
475
+ # @return [Integer]
476
+ def available_movement(battle)
477
+ grappled? ? 0 : battle.entity_state_for(self)[:movement]
478
+ end
479
+
480
+ def speed
481
+ @properties[:speed]
482
+ end
483
+
484
+ def has_reaction?(battle)
485
+ (battle.entity_state_for(self)[:reaction].presence || 0).positive?
486
+ end
487
+
488
+ def str_mod
489
+ modifier_table(@ability_scores.fetch(:str))
490
+ end
491
+
492
+ def con_mod
493
+ modifier_table(@ability_scores.fetch(:con))
494
+ end
495
+
496
+ def wis_mod
497
+ modifier_table(@ability_scores.fetch(:wis))
498
+ end
499
+
500
+ def cha_mod
501
+ modifier_table(@ability_scores.fetch(:cha))
502
+ end
503
+
504
+ def int_mod
505
+ modifier_table(@ability_scores.fetch(:int))
506
+ end
507
+
508
+ def dex_mod
509
+ modifier_table(@ability_scores.fetch(:dex))
510
+ end
511
+
512
+ def ability_mod(type)
513
+ mod_type = case type.to_sym
514
+ when :wisdom, :wis
515
+ :wis
516
+ when :dexterity, :dex
517
+ :dex
518
+ when :constitution, :con
519
+ :con
520
+ when :intelligence, :int
521
+ :int
522
+ when :charisma, :cha
523
+ :cha
524
+ when :strength, :str
525
+ :str
526
+ end
527
+ modifier_table(@ability_scores.fetch(mod_type))
528
+ end
529
+
530
+ def passive_perception
531
+ @properties[:passive_perception] || 10 + wis_mod
532
+ end
533
+
534
+ def standing_jump_distance
535
+ (@ability_scores.fetch(:str) / 2).floor
536
+ end
537
+
538
+ def long_jump_distance
539
+ @ability_scores.fetch(:str)
540
+ end
541
+
542
+ def expertise
543
+ @properties.fetch(:expertise, [])
544
+ end
545
+
546
+ def token_size
547
+ square_size = size.to_sym
548
+ case square_size
549
+ when :small
550
+ 1
551
+ when :medium
552
+ 1
553
+ when :large
554
+ 2
555
+ when :huge
556
+ 3
557
+ else
558
+ raise "invalid size #{square_size}"
559
+ end
560
+ end
561
+
562
+ def size_identifier
563
+ square_size = size.to_sym
564
+ case square_size
565
+ when :small
566
+ 1
567
+ when :medium
568
+ 2
569
+ when :large
570
+ 3
571
+ when :huge
572
+ 4
573
+ when :gargantuan
574
+ 5
575
+ else
576
+ raise "invalid size #{square_size}"
577
+ end
578
+ end
579
+
580
+ ALL_SKILLS = %i[acrobatics animal_handling arcana athletics deception history insight intimidation
581
+ investigation medicine nature perception performance persuasion religion sleight_of_hand stealth survival]
582
+ SKILL_AND_ABILITY_MAP = {
583
+ dex: %i[acrobatics sleight_of_hand stealth],
584
+ wis: %i[animal_handling insight medicine perception survival],
585
+ int: %i[arcana history investigation nature religion],
586
+ con: [],
587
+ str: [:athletics],
588
+ cha: %i[deception intimidation performance persuasion]
589
+ }
590
+
591
+ SKILL_AND_ABILITY_MAP.each do |ability, skills|
592
+ skills.each do |skill|
593
+ define_method("#{skill}_mod") do
594
+ ability_mod = case ability.to_sym
595
+ when :dex
596
+ dex_mod
597
+ when :wis
598
+ wis_mod
599
+ when :cha
600
+ cha_mod
601
+ when :con
602
+ con_mod
603
+ when :str
604
+ str_mod
605
+ when :int
606
+ int_mod
607
+ end
608
+ bonus = if send(:proficient?, skill)
609
+ expertise?(skill) ? proficiency_bonus * 2 : proficiency_bonus
610
+ else
611
+ 0
612
+ end
613
+ ability_mod + bonus
614
+ end
615
+
616
+ define_method("#{skill}_check!") do |battle = nil, opts = {}|
617
+ modifiers = send(:"#{skill}_mod")
618
+ DieRoll.roll_with_lucky(self, "1d20+#{modifiers}", description: opts.fetch(:description, t("dice_roll.#{skill}")),
619
+ battle: battle)
620
+ end
621
+ end
622
+ end
623
+
624
+ def dexterity_check!(bonus = 0, battle: nil, description: nil)
625
+ disadvantage = !proficient_with_equipped_armor? ? true : false
626
+ DieRoll.roll_with_lucky(self, "1d20+#{dex_mod + bonus}", disadvantage: disadvantage, description: description || t('dice_roll.dexterity'),
627
+ battle: battle)
628
+ end
629
+
630
+ def strength_check!(bonus = 0, battle: nil, description: nil)
631
+ disadvantage = !proficient_with_equipped_armor? ? true : false
632
+ DieRoll.roll_with_lucky(self, "1d20+#{str_mod + bonus}", disadvantage: disadvantage, description: description || t('dice_roll.stength_check'),
633
+ battle: battle)
634
+ end
635
+
636
+ def wisdom_check!(bonus = 0, battle: nil, description: nil)
637
+ DieRoll.roll_with_lucky(self, "1d20+#{wis_mod + bonus}", description: description || t('dice_roll.wisdom_check'),
638
+ battle: battle)
639
+ end
640
+
641
+ def medicine_check!(battle = nil, description: nil)
642
+ wisdom_check!(medicine_proficient? ? proficiency_bonus : 0, battle: battle,
643
+ description: description || t('dice_roll.medicine'))
644
+ end
645
+
646
+ def attach_handler(event_name, object, callback)
647
+ @event_handlers ||= {}
648
+ @event_handlers[event_name.to_sym] = [object, callback]
649
+ end
650
+
651
+ def trigger_event(event_name, battle, session, map, event)
652
+ @event_handlers ||= {}
653
+ return unless @event_handlers.key?(event_name.to_sym)
654
+
655
+ object, method_name = @event_handlers[event_name.to_sym]
656
+ object.send(method_name.to_sym, battle, session, self, map, event)
657
+ end
658
+
659
+ # @param target [Natural20::Entity]
660
+ def grappling(target)
661
+ @grappling ||= []
662
+ @grappling << target
663
+ end
664
+
665
+ def grappling?
666
+ @grappling ||= []
667
+
668
+ !@grappling.empty?
669
+ end
670
+
671
+ def grappling_targets
672
+ @grappling ||= []
673
+ @grappling
674
+ end
675
+
676
+ # @param target [Natural20::Entity]
677
+ def ungrapple(target)
678
+ @grappling ||= []
679
+ @grappling.delete(target)
680
+ target.grapples.delete(self)
681
+ target.statuses.delete(:grappled) if target.grapples.empty?
682
+ end
683
+
684
+ def drop_grapple!
685
+ @grappling ||= []
686
+ @grappling.each do |target|
687
+ ungrapple(target)
688
+ end
689
+ end
690
+
691
+ # Removes Item from inventory
692
+ # @param ammo_type [Symbol,String]
693
+ # @param amount [Integer]
694
+ # @return [OpenStruct]
695
+ def deduct_item(ammo_type, amount = 1)
696
+ return if @inventory[ammo_type.to_sym].nil?
697
+
698
+ qty = @inventory[ammo_type.to_sym].qty
699
+ @inventory[ammo_type.to_sym].qty = [qty - amount, 0].max
700
+
701
+ @inventory[ammo_type.to_sym]
702
+ end
703
+
704
+ # Adds an item to your inventory
705
+ # @param ammo_type [Symbol,String]
706
+ # @param amount [Integer]
707
+ # @param source_item [Object]
708
+ def add_item(ammo_type, amount = 1, source_item = nil)
709
+ if @inventory[ammo_type.to_sym].nil?
710
+ @inventory[ammo_type.to_sym] =
711
+ OpenStruct.new(qty: 0, type: source_item&.type || ammo_type.to_sym)
712
+ end
713
+
714
+ qty = @inventory[ammo_type.to_sym].qty
715
+ @inventory[ammo_type.to_sym].qty = qty + amount
716
+ end
717
+
718
+ # Retrieves the item count of an item in the entities inventory
719
+ # @param inventory_type [Symbol]
720
+ # @return [Integer]
721
+ def item_count(inventory_type)
722
+ return 0 if @inventory[inventory_type.to_sym].nil?
723
+
724
+ @inventory[inventory_type.to_sym][:qty]
725
+ end
726
+
727
+ def usable_items
728
+ @inventory.map do |k, v|
729
+ item_details =
730
+ session.load_equipment(v.type)
731
+
732
+ next unless item_details
733
+ next unless item_details[:usable]
734
+ next if item_details[:consumable] && v.qty.zero?
735
+
736
+ { name: k.to_s, label: item_details[:name] || k, item: item_details, qty: v.qty,
737
+ consumable: item_details[:consumable] }
738
+ end.compact
739
+ end
740
+
741
+ # @param battle [Natural20::Batle
742
+ # @param item_and_counts [Array<Array<OpenStruct,Integer>>]
743
+ def drop_items!(battle, item_and_counts = [])
744
+ ground = battle.map.ground_at(*battle.map.entity_or_object_pos(self))
745
+ ground&.store(battle, self, ground, item_and_counts)
746
+ end
747
+
748
+ # Show usable objects near the entity
749
+ # @param map [Natural20::BattleMap]
750
+ # @param battle [Natural20::Battle]
751
+ # @return [Array]
752
+ def usable_objects(map, battle)
753
+ map.objects_near(self, battle)
754
+ end
755
+
756
+ # Returns items in the "backpack" of the entity
757
+ # @return [Array]
758
+ def inventory
759
+ @inventory.map do |k, v|
760
+ item = @session.load_thing k
761
+ raise "unable to load unknown item #{k}" if item.nil?
762
+ next unless v[:qty].positive?
763
+
764
+ OpenStruct.new(
765
+ name: k.to_sym,
766
+ label: v[:label].presence || k.to_s.humanize,
767
+ qty: v[:qty],
768
+ equipped: false,
769
+ weight: item[:weight]
770
+ )
771
+ end.compact
772
+ end
773
+
774
+ def inventory_count
775
+ @inventory.values.inject(0) do |total, item|
776
+ total + item[:qty]
777
+ end
778
+ end
779
+
780
+ # Unequips a weapon
781
+ # @param item_name [String,Symbol]
782
+ # @param transfer_inventory [Boolean] Add this item to the inventory?
783
+ def unequip(item_name, transfer_inventory: true)
784
+ add_item(item_name.to_sym) if @properties[:equipped].delete(item_name.to_s) && transfer_inventory
785
+ end
786
+
787
+ # removes all equiped. Used for tests
788
+ def unequip_all
789
+ @properties[:equipped].clear
790
+ end
791
+
792
+ # Checks if an item is equipped
793
+ # @param item_name [String,Symbol]
794
+ # @return [Boolean]
795
+ def equipped?(item_name)
796
+ equipped_items.map(&:name).include?(item_name.to_sym)
797
+ end
798
+
799
+ # Equips an item
800
+ # @param item_name [String,Symbol]
801
+ def equip(item_name, ignore_inventory: false)
802
+ return @properties[:equipped] << item_name.to_s if ignore_inventory
803
+
804
+ item = deduct_item(item_name)
805
+ @properties[:equipped] << item_name.to_s if item
806
+ end
807
+
808
+ # Checks if item can be equipped
809
+ # @param item_name [String,Symbol]
810
+ # @return [Symbol]
811
+ def check_equip(item_name)
812
+ item_name = item_name.to_sym
813
+ weapon = @session.load_thing(item_name)
814
+ return :unequippable unless weapon && weapon[:subtype] == 'weapon' || %w[shield armor].include?(weapon[:type])
815
+
816
+ hand_slots = used_hand_slots + hand_slots_required(to_item(item_name, weapon))
817
+
818
+ armor_slots = equipped_items.select do |item|
819
+ item.type == 'armor'
820
+ end.size
821
+
822
+ return :hands_full if hand_slots > 2.0
823
+ return :armor_equipped if armor_slots >= 1 && weapon[:type] == 'armor'
824
+
825
+ :ok
826
+ end
827
+
828
+ def proficient_with_armor?(item)
829
+ armor = @session.load_thing(item)
830
+ raise "unknown item #{item}" unless armor
831
+ raise "not armor #{item}" unless %w[armor shield].include?(armor[:type])
832
+
833
+ return proficient?("#{armor[:subtype]}_armor") if armor[:type] == 'armor'
834
+ return proficient?('shields') if armor[:type] == 'shield'
835
+
836
+ false
837
+ end
838
+
839
+ def proficient_with_equipped_armor?
840
+ shields_and_armor = equipped_items.select { |t| %w[armor shield].include?(t[:type]) }
841
+ return true if shields_and_armor.empty?
842
+
843
+ shields_and_armor.each do |item|
844
+ return false unless proficient_with_armor?(item.name)
845
+ end
846
+
847
+ true
848
+ end
849
+
850
+ def hand_slots_required(item)
851
+ return 0.0 if item.type == 'armor'
852
+
853
+ if item.light
854
+ 0.5
855
+ elsif item.two_handed
856
+ 2.0
857
+ else
858
+ 1.0
859
+ end
860
+ end
861
+
862
+ def used_hand_slots(weapon_only: false)
863
+ equipped_items.select do |item|
864
+ item.subtype == 'weapon' || (!weapon_only && item.type == 'shield')
865
+ end.inject(0.0) do |slot, item|
866
+ slot + hand_slots_required(item)
867
+ end
868
+ end
869
+
870
+ def equipped_weapons
871
+ equipped_items.select do |item|
872
+ item.subtype == 'weapon'
873
+ end.map(&:name)
874
+ end
875
+
876
+ def proficient?(prof)
877
+ @properties[:skills]&.include?(prof.to_s) ||
878
+ @properties[:tools]&.include?(prof.to_s) ||
879
+
880
+ @properties[:saving_throw_proficiencies]&.map { |s| "#{s}_save" }&.include?(prof.to_s)
881
+ end
882
+
883
+ def opened?
884
+ false
885
+ end
886
+
887
+ def incapacitated?
888
+ @statuses.include?(:unconscious) || @statuses.include?(:sleep) || @statuses.include?(:dead)
889
+ end
890
+
891
+ def grappled?
892
+ @statuses.include?(:grappled)
893
+ end
894
+
895
+ # Returns tghe proficiency bonus of this entity
896
+ # @return [Integer]
897
+ def proficiency_bonus
898
+ @properties[:proficiency_bonus].presence || 2
899
+ end
900
+
901
+ # returns in lbs the weight of all items in the inventory
902
+ # @return [Float] weight in lbs
903
+ def inventory_weight
904
+ (inventory + equipped_items).inject(0.0) do |sum, item|
905
+ sum + (item.weight.presence || '0').to_f * item.qty
906
+ end
907
+ end
908
+
909
+ # returns equipped items
910
+ # @return [Array<OpenStruct>] A List of items
911
+ def equipped_items
912
+ equipped_arr = @properties[:equipped] || []
913
+ equipped_arr.map do |k|
914
+ item = @session.load_thing(k)
915
+ raise "unknown item #{k}" unless item
916
+
917
+ to_item(k, item)
918
+ end
919
+ end
920
+
921
+ def shield_equipped?
922
+ @equipments ||= YAML.load_file(File.join(session.root_path, 'items', 'equipment.yml')).deep_symbolize_keys!
923
+
924
+ equipped_meta = @equipped.map { |e| @equipments[e.to_sym] }.compact
925
+ !!equipped_meta.detect do |s|
926
+ s[:type] == 'shield'
927
+ end
928
+ end
929
+
930
+ def to_item(k, item)
931
+ OpenStruct.new(
932
+ name: k.to_sym,
933
+ label: item[:label].presence || k.to_s.humanize,
934
+ type: item[:type],
935
+ subtype: item[:subtype],
936
+ light: item[:properties].try(:include?, 'light'),
937
+ two_handed: item[:properties].try(:include?, 'two_handed'),
938
+ light_properties: item[:light],
939
+ proficiency_type: item[:proficiency_type],
940
+ qty: 1,
941
+ equipped: true,
942
+ weight: item[:weight]
943
+ )
944
+ end
945
+
946
+ # returns the carrying capacity of an entity in lbs
947
+ # @return [Float] carrying capacity in lbs
948
+ def carry_capacity
949
+ @ability_scores.fetch(:str, 1) * 15.0
950
+ end
951
+
952
+ def items_label
953
+ I18n.t(:"entity.#{self.class}.item_label", default: "#{name} Items")
954
+ end
955
+
956
+ def perception_proficient?
957
+ proficient?('perception')
958
+ end
959
+
960
+ def investigation_proficient?
961
+ proficient?('investigation')
962
+ end
963
+
964
+ def insight_proficient?
965
+ proficient?('insight')
966
+ end
967
+
968
+ def stealth_proficient?
969
+ proficient?('stealth')
970
+ end
971
+
972
+ def acrobatics_proficient?
973
+ proficient?('acrobatics')
974
+ end
975
+
976
+ def athletics_proficient?
977
+ proficient?('athletics')
978
+ end
979
+
980
+ def medicine_proficient?
981
+ proficient?('medicine')
982
+ end
983
+
984
+ def lockpick!(battle = nil)
985
+ proficiency_mod = dex_mod
986
+ bonus = if proficient?(:thieves_tools)
987
+ expertise?(:thieves_tools) ? proficiency_bonus * 2 : proficiency_bonus
988
+ else
989
+ 0
990
+ end
991
+ proficiency_mod += bonus
992
+ Natural20::DieRoll.roll("1d20+#{proficiency_mod}", description: t('dice_roll.thieves_tools'), battle: battle,
993
+ entity: self)
994
+ end
995
+
996
+ def class_feature?(feature)
997
+ @properties[:attributes]&.include?(feature)
998
+ end
999
+
1000
+ # checks if at least one class feature matches
1001
+ # @param features [Array<String>]
1002
+ # @return [Boolean]
1003
+ def any_class_feature?(features)
1004
+ !features.select { |f| class_feature?(f) }.empty?
1005
+ end
1006
+
1007
+ def light_properties
1008
+ return nil if equipped_items.blank?
1009
+
1010
+ bright = [0]
1011
+ dim = [0]
1012
+
1013
+ equipped_items.map do |item|
1014
+ next unless item.light_properties
1015
+
1016
+ bright << item.light_properties.fetch(:bright, 0)
1017
+ dim << item.light_properties.fetch(:dim, 0)
1018
+ end
1019
+
1020
+ bright = bright.max
1021
+ dim = dim.max
1022
+
1023
+ return nil unless [dim, bright].sum.positive?
1024
+
1025
+ { dim: dim,
1026
+ bright: bright }
1027
+ end
1028
+
1029
+ def attack_roll_mod(weapon)
1030
+ modifier = attack_ability_mod(weapon)
1031
+
1032
+ modifier += proficiency_bonus if proficient_with_weapon?(weapon)
1033
+
1034
+ modifier
1035
+ end
1036
+
1037
+ def attack_ability_mod(weapon)
1038
+ modifier = 0
1039
+
1040
+ modifier += case (weapon[:type])
1041
+ when 'melee_attack'
1042
+ weapon[:properties]&.include?('finesse') ? [str_mod, dex_mod].max : str_mod
1043
+ when 'ranged_attack'
1044
+ if class_feature?('archery')
1045
+ dex_mod + 2
1046
+ else
1047
+ dex_mod
1048
+ end
1049
+ end
1050
+
1051
+ modifier
1052
+ end
1053
+
1054
+ def proficient_with_weapon?(weapon)
1055
+ weapon = @session.load_thing weapon if weapon.is_a?(String)
1056
+
1057
+ return true if weapon[:name] == 'Unarmed Attack'
1058
+
1059
+ @properties[:weapon_proficiencies]&.detect do |prof|
1060
+ weapon[:proficiency_type]&.include?(prof)
1061
+ end
1062
+ end
1063
+
1064
+ # Returns the character hit die
1065
+ # @return [Hash<Integer,Integer>]
1066
+ def hit_die
1067
+ @current_hit_die
1068
+ end
1069
+
1070
+ def squeezed!
1071
+ @statuses.add(:squeezed)
1072
+ end
1073
+
1074
+ def unsqueeze
1075
+ @statuses.delete(:squeezed)
1076
+ end
1077
+
1078
+ def squeezed?
1079
+ @statuses.include?(:squeezed)
1080
+ end
1081
+
1082
+ # @param hit_die_num [Integer] number of hit die to use
1083
+ def short_rest!(battle, prompt: false)
1084
+ controller = battle&.controller_for(self)
1085
+
1086
+ # hit die management
1087
+ if prompt && controller && controller.respond_to?(:prompt_hit_die_roll)
1088
+ loop do
1089
+ break unless @current_hit_die.values.inject(0) { |sum, d| sum + d }.positive?
1090
+
1091
+ ans = battle.controller_for(self)&.try(:prompt_hit_die_roll, self, @current_hit_die.select do |_k, v|
1092
+ v.positive?
1093
+ end.keys)
1094
+
1095
+ if ans == :skip
1096
+ break
1097
+ else
1098
+ use_hit_die!(ans, battle: battle)
1099
+ end
1100
+ end
1101
+ else
1102
+ while @hp < max_hp
1103
+ available_die = @current_hit_die.map do |die, num|
1104
+ next unless num.positive?
1105
+
1106
+ die
1107
+ end.compact.sort
1108
+
1109
+ break if available_die.empty?
1110
+
1111
+ old_hp = @hp
1112
+
1113
+ use_hit_die!(available_die.first, battle: battle)
1114
+
1115
+ break if @hp == old_hp # break if unable to heal
1116
+ end
1117
+ end
1118
+
1119
+ heal!(1) if unconscious? && stable?
1120
+ end
1121
+
1122
+ def use_hit_die!(die_type, battle: nil)
1123
+ return unless @current_hit_die.key? die_type
1124
+ return unless @current_hit_die[die_type].positive?
1125
+
1126
+ @current_hit_die[die_type] -= 1
1127
+
1128
+ hit_die_roll = DieRoll.roll("d#{die_type}", battle: battle, entity: self, description: t('dice_roll.hit_die'))
1129
+
1130
+ EventManager.received_event({ source: self, event: :hit_die, roll: hit_die_roll })
1131
+
1132
+ heal!(hit_die_roll.result)
1133
+ end
1134
+
1135
+ def saving_throw!(save_type, battle: nil)
1136
+ modifier = ability_mod(save_type)
1137
+ modifier += proficiency_bonus if proficient?("#{save_type}_save")
1138
+ op = modifier >= 0 ? '+' : ''
1139
+ disadvantage = %i[dex str].include?(save_type.to_sym) && !proficient_with_equipped_armor? ? true : false
1140
+ DieRoll.roll("d20#{op}#{modifier}", disadvantage: disadvantage, battle: battle, entity: self,
1141
+ description: t("dice_roll.#{save_type}_saving_throw"))
1142
+ end
1143
+
1144
+ protected
1145
+
1146
+ # Localization helper
1147
+ # @param token [Symbol, String]
1148
+ # @param options [Hash]
1149
+ # @return [String]
1150
+ def t(token, options = {})
1151
+ I18n.t(token, options)
1152
+ end
1153
+
1154
+ def setup_attributes
1155
+ @death_saves = 0
1156
+ @death_fails = 0
1157
+ end
1158
+
1159
+ def on_take_damage(battle, _damage_params)
1160
+ controller = battle.controller_for(self)
1161
+ controller.attack_listener(battle, self) if controller && controller.respond_to?(:attack_listener)
1162
+ end
1163
+
1164
+ def modifier_table(value)
1165
+ mod_table = [[1, 1, -5],
1166
+ [2, 3, -4],
1167
+ [4, 5, -3],
1168
+ [6, 7, -2],
1169
+ [8, 9, -1],
1170
+ [10, 11, 0],
1171
+ [12, 13, 1],
1172
+ [14, 15, 2],
1173
+ [16, 17, 3],
1174
+ [18, 19, 4],
1175
+ [20, 21, 5],
1176
+ [22, 23, 6],
1177
+ [24, 25, 7],
1178
+ [26, 27, 8],
1179
+ [28, 29, 9],
1180
+ [30, 30, 10]]
1181
+
1182
+ mod_table.each do |row|
1183
+ low, high, mod = row
1184
+ return mod if value.between?(low, high)
1185
+ end
1186
+ end
1187
+
1188
+ def character_advancement_table
1189
+ [
1190
+ [0, 1, 2],
1191
+ [300, 2, 2],
1192
+ [900, 3, 2],
1193
+ [2700, 4, 2],
1194
+ [6500, 5, 3],
1195
+ [14_000, 6, 3],
1196
+ [23_000, 7, 3],
1197
+ [34_000, 8, 3],
1198
+ [48_000, 9, 4],
1199
+ [64_000, 10, 4],
1200
+ [85_000, 11, 4],
1201
+ [100_000, 12, 4],
1202
+ [120_000, 13, 5],
1203
+ [140_000, 14, 5],
1204
+ [165_000, 15, 5],
1205
+ [195_000, 16, 5],
1206
+ [225_000, 17, 6],
1207
+ [265_000, 18, 6],
1208
+ [305_000, 19, 6],
1209
+ [355_000, 20, 6]
1210
+ ]
1211
+ end
1212
+ end
1213
+ end