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,69 @@
1
+ module ItemLibrary
2
+ # Represents a staple of DnD the concealed pit trap
3
+ class PitTrap < Object
4
+ include AreaTrigger
5
+
6
+ attr_accessor :activated
7
+
8
+ def area_trigger_handler(entity, entity_pos, is_flying)
9
+ result = []
10
+ return nil if entity_pos != position
11
+ return nil if is_flying
12
+
13
+ unless activated
14
+ damage = Natural20::DieRoll.roll(@properties[:damage_die])
15
+ result = [
16
+ {
17
+ source: self,
18
+ type: :state,
19
+ params: {
20
+ activated: true
21
+ }
22
+ },
23
+ {
24
+ source: self,
25
+ target: entity,
26
+ type: :damage,
27
+ attack_name: @properties[:attack_name] || 'pit trap',
28
+ damage_type: @properties[:damage_type] || 'piercing',
29
+ damage: damage
30
+ }
31
+ ]
32
+ end
33
+
34
+ result
35
+ end
36
+
37
+ def placeable?
38
+ !activated
39
+ end
40
+
41
+ def label
42
+ return 'ground' unless activated
43
+
44
+ @properties[:name].presence || 'pit trap'
45
+ end
46
+
47
+ def passable?
48
+ true
49
+ end
50
+
51
+ def token
52
+ ["\u02ac"]
53
+ end
54
+
55
+ def concealed?
56
+ !activated
57
+ end
58
+
59
+ def jump_required?
60
+ activated
61
+ end
62
+
63
+ protected
64
+
65
+ def setup_other_attributes
66
+ @activated = false
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,18 @@
1
+ module ItemLibrary
2
+ # Represents a staple of DnD the concealed pit trap
3
+ class StoneWall < Object
4
+ def opaque?
5
+ !dead?
6
+ end
7
+
8
+ def passable?
9
+ dead?
10
+ end
11
+
12
+ def token
13
+ return ['`'] if dead?
14
+
15
+ ['#']
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,173 @@
1
+ # typed: false
2
+ require "random_name_generator"
3
+
4
+ module Natural20
5
+ class Npc
6
+ include Natural20::Entity
7
+ include Natural20::Notable
8
+ prepend Natural20::Lootable
9
+ include Natural20::HealthFlavor
10
+ include Multiattack
11
+
12
+ attr_accessor :hp, :resistances, :npc_actions, :battle_defaults, :npc_type
13
+
14
+ # @param session [Session]
15
+ # @param type [String,Symbol]
16
+ # @option opt rand_life [Boolean] Determines if will use die for npc HP instead of fixed value
17
+ def initialize(session, type, opt = {})
18
+ @properties = YAML.load_file(File.join("npcs", "#{type}.yml")).deep_symbolize_keys!
19
+ @properties.merge!(opt[:overrides].presence || {})
20
+ @ability_scores = @properties[:ability]
21
+ @color = @properties[:color]
22
+ @session = session
23
+ @npc_type = type
24
+ @inventory = @properties[:default_inventory]&.map do |inventory|
25
+ [inventory[:type].to_sym, OpenStruct.new({ qty: inventory[:qty] })]
26
+ end.to_h || {}
27
+
28
+ @properties[:inventory]&.each do |inventory|
29
+ @inventory[inventory[:type].to_sym] = OpenStruct.new({ qty: inventory[:qty] })
30
+ end
31
+
32
+ @npc_actions = @properties[:actions]
33
+ @battle_defaults = @properties[:battle_defaults]
34
+ @opt = opt
35
+ @resistances = []
36
+ @statuses = Set.new
37
+
38
+ @properties[:statuses]&.each do |stat|
39
+ @statuses.add(stat.to_sym)
40
+ end
41
+
42
+ name = case type
43
+ when "goblin"
44
+ RandomNameGenerator.new(RandomNameGenerator::GOBLIN).compose(1)
45
+ when "ogre"
46
+ %w[Guzar Irth Grukurg Zoduk].sample(1).first
47
+ else
48
+ type.to_s.humanize
49
+ end
50
+ @name = opt.fetch(:name, name)
51
+ @entity_uid = SecureRandom.uuid
52
+ setup_attributes
53
+ end
54
+
55
+ attr_reader :name
56
+
57
+ def kind
58
+ @properties[:kind]
59
+ end
60
+
61
+ def size
62
+ @properties[:size]
63
+ end
64
+
65
+ def token
66
+ @properties[:token]
67
+ end
68
+
69
+ attr_reader :name
70
+
71
+ attr_reader :max_hp
72
+
73
+ def npc?
74
+ true
75
+ end
76
+
77
+ def armor_class
78
+ @properties[:default_ac]
79
+ end
80
+
81
+ def speed
82
+ @properties[:speed]
83
+ end
84
+
85
+ def available_actions(session, battle, opportunity_attack: false)
86
+ return %i[end] if unconscious?
87
+
88
+ if opportunity_attack
89
+ return generate_npc_attack_actions(battle, opportunity_attack: true).select do |s|
90
+ s.action_type == :attack && s.npc_action[:type] == 'melee_attack'
91
+ end
92
+ end
93
+
94
+ [ generate_npc_attack_actions(battle) +
95
+
96
+ %i[hide dodge look stand move dash grapple escape_grapple].map do |type|
97
+ next unless "#{type.to_s.camelize}Action".constantize.can?(self, battle)
98
+ case type
99
+ when :dodge
100
+ DodgeAction.new(session, self, :dodge)
101
+ when :hide
102
+ HideAction.new(session, self, :hide)
103
+ when :disengage
104
+ action = DisengageAction.new(session, self, :disengage)
105
+ action
106
+ when :move
107
+ MoveAction.new(session, self, type)
108
+ when :stand
109
+ StandAction.new(session, self, type)
110
+ when :dash
111
+ action = DashAction.new(session, self, type)
112
+ action
113
+ when :help
114
+ action = HelpAction.new(session, self, :help)
115
+ action
116
+ else
117
+ Natural20::Action.new(session, self, type)
118
+ end
119
+ end.compact].flatten
120
+ end
121
+
122
+ def melee_distance
123
+ @properties[:actions].select { |a| a[:type] == "melee_attack" }.map do |action|
124
+ action[:range]
125
+ end&.max
126
+ end
127
+
128
+ def class_feature?(feature)
129
+ @properties[:attributes]&.include?(feature)
130
+ end
131
+
132
+ def available_interactions(entity, battle)
133
+ []
134
+ end
135
+
136
+ def proficient_with_equipped_armor?
137
+ true
138
+ end
139
+
140
+ def generate_npc_attack_actions(battle, opportunity_attack: false)
141
+ actions = []
142
+
143
+ actions += npc_actions.map do |npc_action|
144
+ next if npc_action[:ammo] && item_count(npc_action[:ammo]) <= 0
145
+ next if npc_action[:if] && !eval_if(npc_action[:if])
146
+ next unless AttackAction.can?(self, battle, npc_action: npc_action, opportunity_attack: opportunity_attack)
147
+
148
+ action = AttackAction.new(session, self, :attack)
149
+
150
+ action.npc_action = npc_action
151
+ action
152
+ end.compact
153
+
154
+ actions
155
+ end
156
+
157
+ private
158
+
159
+ def setup_attributes
160
+ super
161
+
162
+ @max_hp = @opt[:rand_life] ? Natural20::DieRoll.roll(@properties[:hp_die]).result : @properties[:max_hp]
163
+ @hp = [@properties.fetch(:override_hp, @max_hp), @max_hp].min
164
+
165
+ # parse hit die details
166
+ hp_details = Natural20::DieRoll.parse(@properties[:hp_die] || "1d6")
167
+ @max_hit_die = {}
168
+ @current_hit_die = {}
169
+ @max_hit_die[npc_type] = hp_details.die_count
170
+ @current_hit_die[hp_details.die_type.to_i] = hp_details.die_count
171
+ end
172
+ end
173
+ end
@@ -0,0 +1,414 @@
1
+ # typed: false
2
+ module Natural20
3
+ class PlayerCharacter
4
+ include Natural20::Entity
5
+ include Natural20::RogueClass
6
+ include Natural20::FighterClass
7
+ include Natural20::HealthFlavor
8
+ prepend Natural20::Lootable
9
+ include Multiattack
10
+
11
+ attr_accessor :hp, :other_counters, :resistances, :experience_points, :class_properties
12
+
13
+ ACTION_LIST = %i[first_aid look attack move dash hide help dodge disengage use_item interact ground_interact inventory disengage_bonus
14
+ dash_bonus hide_bonus grapple escape_grapple drop_grapple shove push prone stand short_rest two_weapon_attack].freeze
15
+
16
+ # @param session [Natural20::Session]
17
+ def initialize(session, properties)
18
+ @session = session
19
+ @properties = properties.deep_symbolize_keys!
20
+
21
+ @ability_scores = @properties[:ability]
22
+ @equipped = @properties[:equipped]
23
+ @race_properties = YAML.load_file(File.join(session.root_path, 'races',
24
+ "#{@properties[:race]}.yml")).deep_symbolize_keys!
25
+ @inventory = {}
26
+ @color = @properties[:color]
27
+ @properties[:inventory]&.each do |inventory|
28
+ @inventory[inventory[:type].to_sym] ||= OpenStruct.new({ type: inventory[:type], qty: 0 })
29
+ @inventory[inventory[:type].to_sym].qty += inventory[:qty]
30
+ end
31
+ @statuses = Set.new
32
+ @resistances = []
33
+ entity_uid = SecureRandom.uuid
34
+ setup_attributes
35
+ @max_hit_die = {}
36
+ @current_hit_die = {}
37
+
38
+ @class_properties = @properties[:classes].map do |klass, level|
39
+ send(:"#{klass}_level=", level)
40
+ send(:"initialize_#{klass}")
41
+
42
+ @max_hit_die[klass] = level
43
+
44
+ character_class_properties =
45
+ YAML.load_file(File.join(session.root_path, 'char_classes', "#{klass}.yml")).deep_symbolize_keys!
46
+ hit_die_details = DieRoll.parse(character_class_properties[:hit_die])
47
+ @current_hit_die[hit_die_details.die_type.to_i] = level
48
+
49
+ [klass.to_sym, character_class_properties]
50
+ end.to_h
51
+ end
52
+
53
+ def name
54
+ @properties[:name]
55
+ end
56
+
57
+ def max_hp
58
+ @properties[:max_hp]
59
+ end
60
+
61
+ def armor_class
62
+ equipped_ac
63
+ end
64
+
65
+ def level
66
+ @properties[:level]
67
+ end
68
+
69
+ def size
70
+ @properties[:size] || @race_properties[:size]
71
+ end
72
+
73
+ def token
74
+ @properties[:token]
75
+ end
76
+
77
+ def speed
78
+ if subrace
79
+ return (@race_properties.dig(:subrace, subrace.to_sym,
80
+ :base_speed) || @race_properties[:base_speed])
81
+ end
82
+ @race_properties[:base_speed]
83
+ end
84
+
85
+ def race
86
+ @properties[:race]
87
+ end
88
+
89
+ def subrace
90
+ @properties[:subrace]
91
+ end
92
+
93
+ def languages
94
+ class_languages = []
95
+ @class_properties.values.each do |prop|
96
+ class_languages += prop[:languages] || []
97
+ end
98
+
99
+ racial_languages = @race_properties[:languages] || []
100
+
101
+ (super + class_languages + racial_languages).sort
102
+ end
103
+
104
+ def c_class
105
+ @properties[:classes]
106
+ end
107
+
108
+ def passive_perception
109
+ 10 + wis_mod + wisdom_proficiency
110
+ end
111
+
112
+ def passive_investigation
113
+ 10 + int_mod + investigation_proficiency
114
+ end
115
+
116
+ def passive_insight
117
+ 10 + wis_mod + insight_proficiency
118
+ end
119
+
120
+ def wisdom_proficiency
121
+ perception_proficient? ? proficiency_bonus : 0
122
+ end
123
+
124
+ def investigation_proficiency
125
+ investigation_proficient? ? proficiency_bonus : 0
126
+ end
127
+
128
+ def insight_proficiency
129
+ insight_proficient? ? proficiency_bonus : 0
130
+ end
131
+
132
+ def proficiency_bonus
133
+ proficiency_bonus_table[level - 1]
134
+ end
135
+
136
+ def proficient?(prof)
137
+ return true if @class_properties.values.detect { |c| c[:proficiencies]&.include?(prof) }
138
+ return true if @race_properties[:skills]&.include?(prof)
139
+ return true if weapon_proficiencies.include?(prof)
140
+
141
+ super
142
+ end
143
+
144
+ def proficient_with_weapon?(weapon)
145
+ weapon = @session.load_thing weapon if weapon.is_a?(String)
146
+
147
+ all_weapon_proficiencies = weapon_proficiencies
148
+
149
+ return true if all_weapon_proficiencies.include?(weapon[:name])
150
+
151
+ all_weapon_proficiencies&.detect do |prof|
152
+ weapon[:proficiency_type]&.include?(prof)
153
+ end
154
+ end
155
+
156
+ def weapon_proficiencies
157
+ all_weapon_proficiencies = @class_properties.values.map do |p|
158
+ p[:weapon_proficiencies]
159
+ end.compact.flatten + @properties.fetch(:weapon_proficiencies, [])
160
+
161
+ all_weapon_proficiencies += @race_properties.fetch(:weapon_proficiencies, [])
162
+ if subrace
163
+ all_weapon_proficiencies += (@race_properties.dig(:subrace, subrace.to_sym,
164
+ :weapon_proficiencies) || [])
165
+ end
166
+
167
+ all_weapon_proficiencies
168
+ end
169
+
170
+ def to_h
171
+ {
172
+ name: name,
173
+ classes: c_class,
174
+ hp: hp,
175
+ ability: {
176
+ str: @ability_scores.fetch(:str),
177
+ dex: @ability_scores.fetch(:dex),
178
+ con: @ability_scores.fetch(:con),
179
+ int: @ability_scores.fetch(:int),
180
+ wis: @ability_scores.fetch(:wis),
181
+ cha: @ability_scores.fetch(:cha)
182
+ },
183
+ passive: {
184
+ perception: passive_perception,
185
+ investigation: passive_investigation,
186
+ insight: passive_insight
187
+ }
188
+ }
189
+ end
190
+
191
+ def melee_distance
192
+ (@properties[:equipped].map do |item|
193
+ weapon_detail = session.load_weapon(item)
194
+ next if weapon_detail.nil?
195
+ next unless weapon_detail[:type] == 'melee_attack'
196
+
197
+ weapon_detail[:range]
198
+ end.compact + [5]).max
199
+ end
200
+
201
+ def darkvision?(distance)
202
+ return true if super
203
+
204
+ !!(@race_properties[:darkvision] && @race_properties[:darkvision] >= distance)
205
+ end
206
+
207
+ def player_character_attack_actions(_battle, opportunity_attack: false)
208
+ # check all equipped and create attack for each
209
+ valid_weapon_types = if opportunity_attack
210
+ %w[melee_attack]
211
+ else
212
+ %w[ranged_attack melee_attack]
213
+ end
214
+
215
+ weapon_attacks = @properties[:equipped].map do |item|
216
+ weapon_detail = session.load_weapon(item)
217
+ next if weapon_detail.nil?
218
+ next unless valid_weapon_types.include?(weapon_detail[:type])
219
+ next if weapon_detail[:ammo] && !item_count(weapon_detail[:ammo]).positive?
220
+
221
+ attacks = []
222
+
223
+ action = AttackAction.new(session, self, :attack)
224
+ action.using = item
225
+ attacks << action
226
+
227
+ if !opportunity_attack && weapon_detail[:properties] && weapon_detail[:properties].include?('thrown')
228
+ action = AttackAction.new(session, self, :attack)
229
+ action.using = item
230
+ action.thrown = true
231
+ attacks << action
232
+ end
233
+
234
+ attacks
235
+ end.flatten.compact
236
+
237
+ unarmed_attack = AttackAction.new(session, self, :attack)
238
+ unarmed_attack.using = 'unarmed_attack'
239
+
240
+ weapon_attacks + [unarmed_attack]
241
+ end
242
+
243
+ def available_actions(session, battle, opportunity_attack: false)
244
+ return [] if unconscious?
245
+
246
+ if opportunity_attack && AttackAction.can?(self, battle, opportunity_attack: true)
247
+ return player_character_attack_actions(battle, opportunity_attack: true)
248
+ end
249
+
250
+ ACTION_LIST.map do |type|
251
+ next unless "#{type.to_s.camelize}Action".constantize.can?(self, battle)
252
+
253
+ case type
254
+ when :look
255
+ LookAction.new(session, self, :look)
256
+ when :attack
257
+ player_character_attack_actions(battle)
258
+ when :dodge
259
+ DodgeAction.new(session, self, :dodge)
260
+ when :help
261
+ action = HelpAction.new(session, self, :help)
262
+ action
263
+ when :hide
264
+ HideAction.new(session, self, :hide)
265
+ when :hide_bonus
266
+ action = HideBonusAction.new(session, self, :hide_bonus)
267
+ action.as_bonus_action = true
268
+ action
269
+ when :disengage_bonus
270
+ action = DisengageAction.new(session, self, :disengage_bonus)
271
+ action.as_bonus_action = true
272
+ action
273
+ when :disengage
274
+ DisengageAction.new(session, self, :disengage)
275
+ when :drop_grapple
276
+ DropGrappleAction.new(session, self, :drop_grapple)
277
+ when :grapple
278
+ GrappleAction.new(session, self, :grapple)
279
+ when :escape_grapple
280
+ EscapeGrappleAction.new(session, self, :escape_grapple)
281
+ when :move
282
+ MoveAction.new(session, self, type)
283
+ when :prone
284
+ ProneAction.new(session, self, type)
285
+ when :stand
286
+ StandAction.new(session, self, type)
287
+ when :short_rest
288
+ ShortRestAction.new(session, self, type)
289
+ when :dash_bonus
290
+ action = DashBonusAction.new(session, self, :dash_bonus)
291
+ action.as_bonus_action = true
292
+ action
293
+ when :dash
294
+ action = DashAction.new(session, self, type)
295
+ action
296
+ when :use_item
297
+ UseItemAction.new(session, self, type)
298
+ when :interact
299
+ InteractAction.new(session, self, type)
300
+ when :ground_interact
301
+ GroundInteractAction.new(session, self, type)
302
+ when :inventory
303
+ InventoryAction.new(session, self, type)
304
+ when :first_aid
305
+ FirstAidAction.new(session, self, type)
306
+ when :shove
307
+ action = ShoveAction.new(session, self, type)
308
+ action.knock_prone = true
309
+ action
310
+ when :two_weapon_attack
311
+ two_weapon_attack_actions(battle)
312
+ when :push
313
+ ShoveAction.new(session, self, type)
314
+ else
315
+ Natural20::Action.new(session, self, type)
316
+ end
317
+ end.compact.flatten + c_class.keys.map { |c| send(:"special_actions_for_#{c}", session, battle) }.flatten
318
+ end
319
+
320
+ def two_weapon_attack_actions(battle)
321
+ @properties[:equipped].each do |item|
322
+ weapon_detail = session.load_weapon(item)
323
+ next if weapon_detail.nil?
324
+ next unless weapon_detail[:type] == 'melee_attack'
325
+
326
+ next unless weapon_detail[:properties] && weapon_detail[:properties].include?('light') && TwoWeaponAttackAction.can?(
327
+ self, battle, weapon: weapon_detail[:name]
328
+ )
329
+
330
+ action = TwoWeaponAttackAction.new(session, self, :attack)
331
+ action.using = item
332
+ return action
333
+ end
334
+ nil
335
+ end
336
+
337
+ def available_interactions(_entity, _battle)
338
+ []
339
+ end
340
+
341
+ def class_feature?(feature)
342
+ return true if @properties[:class_features]&.include?(feature)
343
+ return true if @properties[:attributes]&.include?(feature)
344
+ return true if @race_properties[:race_features]&.include?(feature)
345
+ return true if subrace && @race_properties.dig(:subrace, subrace.to_sym, :class_features)&.include?(feature)
346
+ return true if subrace && @race_properties.dig(:subrace, subrace.to_sym, :race_features)&.include?(feature)
347
+
348
+ @class_properties.values.detect { |p| p[:class_features]&.include?(feature) }
349
+ end
350
+
351
+ # Loads a pregen character from path
352
+ # @param session [Natural20::Session] The session to use
353
+ # @param path [String] path to character sheet YAML
354
+ # @apram override [Hash] override attributes
355
+ # @return [Natural20::PlayerCharacter] An instance of PlayerCharacter
356
+ def self.load(session, path, override = {})
357
+ Natural20::PlayerCharacter.new(session, YAML.load_file(path).deep_symbolize_keys!.merge(override))
358
+ end
359
+
360
+ # returns if an npc or a player character
361
+ # @return [Boolean]
362
+ def npc?
363
+ false
364
+ end
365
+
366
+ def pc?
367
+ true
368
+ end
369
+
370
+ # @param hit_die_num [Integer] number of hit die to use
371
+ def short_rest!(battle, prompt: false)
372
+ super
373
+
374
+ @class_properties.keys do |klass|
375
+ send(:"short_rest_for_#{klass}") if respond_to?(:"short_rest_for_#{klass}")
376
+ end
377
+ end
378
+
379
+ private
380
+
381
+ def proficiency_bonus_table
382
+ [2, 2, 2, 2, 3, 3, 3, 3, 4, 4, 4, 4, 5, 5, 5, 5, 6, 6, 6, 6]
383
+ end
384
+
385
+ def setup_attributes
386
+ super
387
+ @hp = @properties[:max_hp]
388
+ end
389
+
390
+ def equipped_ac
391
+ @equipments ||= YAML.load_file(File.join(session.root_path, 'items', 'equipment.yml')).deep_symbolize_keys!
392
+
393
+ equipped_meta = @equipped.map { |e| @equipments[e.to_sym] }.compact
394
+ armor = equipped_meta.detect do |equipment|
395
+ equipment[:type] == 'armor'
396
+ end
397
+
398
+ shield = equipped_meta.detect { |e| e[:type] == 'shield' }
399
+
400
+ armor_ac = if armor.nil?
401
+ 10 + dex_mod
402
+ else
403
+ armor[:ac] + (if armor[:mod_cap]
404
+ [dex_mod,
405
+ armor[:mod_cap]].min
406
+ else
407
+ dex_mod
408
+ end) + (class_feature?('defense') ? 1 : 0)
409
+ end
410
+
411
+ armor_ac + (shield.nil? ? 0 : shield[:bonus_ac])
412
+ end
413
+ end
414
+ end