natural_20 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (114) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +14 -0
  3. data/.rspec +3 -0
  4. data/.travis.yml +6 -0
  5. data/CODE_OF_CONDUCT.md +74 -0
  6. data/Gemfile +7 -0
  7. data/LICENSE.txt +21 -0
  8. data/README.md +99 -0
  9. data/Rakefile +6 -0
  10. data/bin/compute_lights +19 -0
  11. data/bin/console +19 -0
  12. data/bin/nat20 +135 -0
  13. data/bin/nat20.cmd +3 -0
  14. data/bin/nat20author +104 -0
  15. data/bin/setup +8 -0
  16. data/char_classes/fighter.yml +45 -0
  17. data/char_classes/rogue.yml +54 -0
  18. data/characters/halfling_rogue.yml +46 -0
  19. data/characters/high_elf_fighter.yml +49 -0
  20. data/fixtures/battle_sim.yml +58 -0
  21. data/fixtures/battle_sim_2.yml +30 -0
  22. data/fixtures/battle_sim_3.yml +26 -0
  23. data/fixtures/battle_sim_4.yml +26 -0
  24. data/fixtures/battle_sim_objects.yml +101 -0
  25. data/fixtures/corridors.yml +24 -0
  26. data/fixtures/elf_rogue.yml +39 -0
  27. data/fixtures/halfling_rogue.yml +41 -0
  28. data/fixtures/high_elf_fighter.yml +49 -0
  29. data/fixtures/human_fighter.yml +48 -0
  30. data/fixtures/path_finding_test.yml +11 -0
  31. data/fixtures/path_finding_test_2.yml +15 -0
  32. data/fixtures/path_finding_test_3.yml +26 -0
  33. data/fixtures/thin_walls.yml +53 -0
  34. data/fixtures/traps.yml +25 -0
  35. data/game.yml +20 -0
  36. data/items/equipment.yml +101 -0
  37. data/items/objects.yml +73 -0
  38. data/items/weapons.yml +297 -0
  39. data/lib/natural_20.rb +68 -0
  40. data/lib/natural_20/actions/action.rb +40 -0
  41. data/lib/natural_20/actions/attack_action.rb +372 -0
  42. data/lib/natural_20/actions/concerns/action_damage.rb +14 -0
  43. data/lib/natural_20/actions/dash_action.rb +46 -0
  44. data/lib/natural_20/actions/disengage_action.rb +53 -0
  45. data/lib/natural_20/actions/dodge_action.rb +45 -0
  46. data/lib/natural_20/actions/escape_grapple_action.rb +97 -0
  47. data/lib/natural_20/actions/first_aid_action.rb +109 -0
  48. data/lib/natural_20/actions/grapple_action.rb +185 -0
  49. data/lib/natural_20/actions/ground_interact_action.rb +74 -0
  50. data/lib/natural_20/actions/help_action.rb +56 -0
  51. data/lib/natural_20/actions/hide_action.rb +53 -0
  52. data/lib/natural_20/actions/interact_action.rb +91 -0
  53. data/lib/natural_20/actions/inventory_action.rb +23 -0
  54. data/lib/natural_20/actions/look_action.rb +63 -0
  55. data/lib/natural_20/actions/move_action.rb +254 -0
  56. data/lib/natural_20/actions/multiattack_action.rb +41 -0
  57. data/lib/natural_20/actions/prone_action.rb +38 -0
  58. data/lib/natural_20/actions/short_rest_action.rb +53 -0
  59. data/lib/natural_20/actions/shove_action.rb +142 -0
  60. data/lib/natural_20/actions/stand_action.rb +47 -0
  61. data/lib/natural_20/actions/use_item_action.rb +57 -0
  62. data/lib/natural_20/ai_controller/path_compute.rb +140 -0
  63. data/lib/natural_20/ai_controller/standard.rb +288 -0
  64. data/lib/natural_20/battle.rb +544 -0
  65. data/lib/natural_20/battle_map.rb +843 -0
  66. data/lib/natural_20/cli/builder/fighter_builder.rb +104 -0
  67. data/lib/natural_20/cli/builder/rogue_builder.rb +62 -0
  68. data/lib/natural_20/cli/character_builder.rb +210 -0
  69. data/lib/natural_20/cli/commandline_ui.rb +612 -0
  70. data/lib/natural_20/cli/inventory_ui.rb +136 -0
  71. data/lib/natural_20/cli/map_renderer.rb +165 -0
  72. data/lib/natural_20/concerns/container.rb +32 -0
  73. data/lib/natural_20/concerns/entity.rb +1213 -0
  74. data/lib/natural_20/concerns/evaluator/entity_state_evaluator.rb +59 -0
  75. data/lib/natural_20/concerns/fighter_actions/second_wind_action.rb +51 -0
  76. data/lib/natural_20/concerns/fighter_class.rb +35 -0
  77. data/lib/natural_20/concerns/health_flavor.rb +27 -0
  78. data/lib/natural_20/concerns/lootable.rb +94 -0
  79. data/lib/natural_20/concerns/movement_helper.rb +195 -0
  80. data/lib/natural_20/concerns/multiattack.rb +54 -0
  81. data/lib/natural_20/concerns/navigation.rb +87 -0
  82. data/lib/natural_20/concerns/notable.rb +37 -0
  83. data/lib/natural_20/concerns/rogue_class.rb +26 -0
  84. data/lib/natural_20/controller.rb +11 -0
  85. data/lib/natural_20/die_roll.rb +331 -0
  86. data/lib/natural_20/event_manager.rb +288 -0
  87. data/lib/natural_20/item_library/base_item.rb +27 -0
  88. data/lib/natural_20/item_library/chest.rb +230 -0
  89. data/lib/natural_20/item_library/door_object.rb +189 -0
  90. data/lib/natural_20/item_library/ground.rb +124 -0
  91. data/lib/natural_20/item_library/healing_potion.rb +51 -0
  92. data/lib/natural_20/item_library/object.rb +153 -0
  93. data/lib/natural_20/item_library/pit_trap.rb +69 -0
  94. data/lib/natural_20/item_library/stone_wall.rb +18 -0
  95. data/lib/natural_20/npc.rb +173 -0
  96. data/lib/natural_20/player_character.rb +414 -0
  97. data/lib/natural_20/session.rb +168 -0
  98. data/lib/natural_20/utils/cover.rb +35 -0
  99. data/lib/natural_20/utils/ray_tracer.rb +90 -0
  100. data/lib/natural_20/utils/static_light_builder.rb +72 -0
  101. data/lib/natural_20/utils/weapons.rb +78 -0
  102. data/lib/natural_20/version.rb +4 -0
  103. data/locales/en.yml +304 -0
  104. data/maps/game_map.yml +168 -0
  105. data/natural_20.gemspec +46 -0
  106. data/npcs/goblin.yml +64 -0
  107. data/npcs/human_guard.yml +48 -0
  108. data/npcs/ogre.yml +61 -0
  109. data/npcs/owlbear.yml +55 -0
  110. data/npcs/wolf.yml +46 -0
  111. data/races/elf.yml +44 -0
  112. data/races/halfling.yml +22 -0
  113. data/races/human.yml +13 -0
  114. metadata +373 -0
@@ -0,0 +1,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