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