natural_20 0.1.1 → 0.2.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 (73) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +1 -0
  3. data/CHANGELOG.md +13 -0
  4. data/README.md +1 -0
  5. data/bin/nat20 +2 -1
  6. data/bin/nat20.cmd +0 -0
  7. data/char_classes/wizard.yml +89 -0
  8. data/characters/high_elf_mage.yml +27 -0
  9. data/fixtures/battle_sim_objects.yml +2 -2
  10. data/fixtures/high_elf_mage.yml +28 -0
  11. data/fixtures/large_map.yml +63 -0
  12. data/game.yml +2 -2
  13. data/items/equipment.yml +30 -0
  14. data/items/objects.yml +33 -29
  15. data/items/spells.yml +58 -0
  16. data/items/weapons.yml +78 -18
  17. data/lib/CHANGELOG.md +0 -0
  18. data/lib/natural_20.rb +9 -0
  19. data/lib/natural_20/actions/action.rb +2 -2
  20. data/lib/natural_20/actions/attack_action.rb +76 -67
  21. data/lib/natural_20/actions/concerns/action_damage.rb +3 -1
  22. data/lib/natural_20/actions/dash_action.rb +7 -10
  23. data/lib/natural_20/actions/disengage_action.rb +11 -12
  24. data/lib/natural_20/actions/dodge_action.rb +7 -8
  25. data/lib/natural_20/actions/escape_grapple_action.rb +16 -18
  26. data/lib/natural_20/actions/first_aid_action.rb +14 -16
  27. data/lib/natural_20/actions/grapple_action.rb +24 -28
  28. data/lib/natural_20/actions/ground_interact_action.rb +1 -3
  29. data/lib/natural_20/actions/help_action.rb +13 -16
  30. data/lib/natural_20/actions/hide_action.rb +7 -9
  31. data/lib/natural_20/actions/interact_action.rb +12 -14
  32. data/lib/natural_20/actions/look_action.rb +14 -15
  33. data/lib/natural_20/actions/move_action.rb +9 -9
  34. data/lib/natural_20/actions/multiattack_action.rb +8 -9
  35. data/lib/natural_20/actions/prone_action.rb +4 -6
  36. data/lib/natural_20/actions/short_rest_action.rb +7 -8
  37. data/lib/natural_20/actions/shove_action.rb +20 -24
  38. data/lib/natural_20/actions/spell_action.rb +89 -0
  39. data/lib/natural_20/actions/stand_action.rb +5 -7
  40. data/lib/natural_20/actions/use_item_action.rb +7 -9
  41. data/lib/natural_20/ai_controller/standard.rb +1 -1
  42. data/lib/natural_20/battle.rb +8 -3
  43. data/lib/natural_20/cli/action_ui.rb +180 -0
  44. data/lib/natural_20/cli/builder/fighter_builder.rb +1 -1
  45. data/lib/natural_20/cli/builder/rogue_builder.rb +10 -10
  46. data/lib/natural_20/cli/builder/wizard_builder.rb +77 -0
  47. data/lib/natural_20/cli/character_builder.rb +9 -4
  48. data/lib/natural_20/cli/commandline_ui.rb +55 -162
  49. data/lib/natural_20/cli/inventory_ui.rb +4 -0
  50. data/lib/natural_20/cli/map_renderer.rb +7 -1
  51. data/lib/natural_20/concerns/attack_helper.rb +53 -0
  52. data/lib/natural_20/concerns/entity.rb +170 -11
  53. data/lib/natural_20/concerns/fighter_actions/second_wind_action.rb +7 -9
  54. data/lib/natural_20/concerns/fighter_class.rb +2 -2
  55. data/lib/natural_20/concerns/spell_attack_helper.rb +33 -0
  56. data/lib/natural_20/concerns/wizard_class.rb +86 -0
  57. data/lib/natural_20/die_roll.rb +2 -2
  58. data/lib/natural_20/event_manager.rb +50 -44
  59. data/lib/natural_20/item_library/base_item.rb +1 -1
  60. data/lib/natural_20/npc.rb +4 -0
  61. data/lib/natural_20/player_character.rb +75 -12
  62. data/lib/natural_20/session.rb +14 -1
  63. data/lib/natural_20/spell_library/firebolt.rb +72 -0
  64. data/lib/natural_20/spell_library/mage_armor.rb +67 -0
  65. data/lib/natural_20/spell_library/mage_hand.rb +2 -0
  66. data/lib/natural_20/spell_library/magic_missile.rb +67 -0
  67. data/lib/natural_20/spell_library/shield.rb +69 -0
  68. data/lib/natural_20/spell_library/spell.rb +31 -0
  69. data/lib/natural_20/utils/weapons.rb +8 -6
  70. data/lib/natural_20/version.rb +1 -1
  71. data/locales/en.yml +44 -8
  72. data/maps/game_map.yml +12 -2
  73. metadata +22 -3
@@ -68,6 +68,7 @@ module Natural20
68
68
  color = (object_meta.color.presence || DEFAULT_TOKEN_COLOR).to_sym
69
69
 
70
70
  return nil unless object_meta.token
71
+ return :inherit if object_meta.token.to_s == 'inherit'
71
72
 
72
73
  if object_meta.token.is_a?(Array)
73
74
  object_meta.token[pos_y - m_y][pos_x - m_x].colorize(color)
@@ -90,7 +91,12 @@ module Natural20
90
91
  when '.', '?'
91
92
  default_ground
92
93
  else
93
- object_token(col_index, row_index)&.colorize(background: background_color) || default_ground
94
+ token = object_token(col_index, row_index)
95
+ if token && token != :inherit
96
+ token&.colorize(background: background_color) || default_ground
97
+ else
98
+ c
99
+ end
94
100
  end
95
101
 
96
102
  # render map layer
@@ -0,0 +1,53 @@
1
+ # Helper module for utilities in computing attacks
2
+ module Natural20::AttackHelper
3
+ # Handles after attack event hooks
4
+ # @param battle [Natural20::Battle]
5
+ # @param target [Natural20::Entity]
6
+ # @param source [Natural20::Entity]
7
+ def after_attack_roll_hook(battle, target, source, attack_roll, effective_ac, opts = {})
8
+ force_miss = false
9
+
10
+ # check prepared spells of target for a possible reaction
11
+ events = target.prepared_spells.map do |spell|
12
+ spell_details = battle.session.load_spell(spell)
13
+ _qty, resource = spell_details[:casting_time].split(':')
14
+ next unless target.has_reaction?(battle)
15
+ next unless resource == 'reaction'
16
+
17
+ spell_class = spell_details[:spell_class].constantize
18
+ next unless spell_class.respond_to?(:after_attack_roll)
19
+
20
+ result, force_miss_result = spell_class.after_attack_roll(battle, target, source, attack_roll,
21
+ effective_ac, opts)
22
+ force_miss = true if force_miss_result == :force_miss
23
+ result
24
+ end.flatten.compact
25
+
26
+ events.each do |item|
27
+ Natural20::Action.descendants.each do |klass|
28
+ klass.apply!(battle, item)
29
+ end
30
+ end
31
+
32
+ force_miss
33
+ end
34
+
35
+ def effective_ac(battle, target)
36
+ cover_ac_adjustments = 0
37
+ ac = if battle.map
38
+ cover_ac_adjustments = calculate_cover_ac(battle.map, target)
39
+ target.armor_class + cover_ac_adjustments # calculate AC with cover
40
+ else
41
+ target.armor_class
42
+ end
43
+ [ac, cover_ac_adjustments]
44
+ end
45
+
46
+ # Computes cover armor class adjustment
47
+ # @param map [Natural20::BattleMap]
48
+ # @param target [Natural20::Entity]
49
+ # @return [Integer]
50
+ def calculate_cover_ac(map, target)
51
+ cover_calculation(map, @source, target)
52
+ end
53
+ end
@@ -3,11 +3,12 @@ module Natural20
3
3
  module Entity
4
4
  include EntityStateEvaluator
5
5
 
6
- attr_accessor :entity_uid, :statuses, :color, :session, :death_saves,
7
- :death_fails, :current_hit_die, :max_hit_die
6
+ attr_accessor :entity_uid, :statuses, :color, :session, :death_saves, :effects,
7
+ :death_fails, :current_hit_die, :max_hit_die, :entity_event_hooks
8
+ attr_reader :casted_effects
8
9
 
9
- ATTRIBUTE_TYPES = %w[strength dexterity constitution intelligence wisdom charisma]
10
- ATTRIBUTE_TYPES_ABBV = %w[str dex con int wis cha]
10
+ ATTRIBUTE_TYPES = %w[strength dexterity constitution intelligence wisdom charisma].freeze
11
+ ATTRIBUTE_TYPES_ABBV = %w[str dex con int wis cha].freeze
11
12
  def label
12
13
  I18n.exists?(name, :en) ? I18n.t(name) : name.humanize
13
14
  end
@@ -56,7 +57,7 @@ module Natural20
56
57
 
57
58
  if unconscious?
58
59
  @statuses.delete(:stable)
59
- @death_fails += if damage_params[:attack_roll].nat_20?
60
+ @death_fails += if damage_params[:attack_roll]&.nat_20?
60
61
  2
61
62
  else
62
63
  1
@@ -351,7 +352,8 @@ module Natural20
351
352
  entity_state[:statuses].delete(:dodge)
352
353
  entity_state[:statuses].delete(:disengage)
353
354
  battle.dismiss_help_actions_for(self)
354
-
355
+ resolve_trigger(:start_of_turn)
356
+ cleanup_effects
355
357
  entity_state
356
358
  end
357
359
 
@@ -389,6 +391,8 @@ module Natural20
389
391
 
390
392
  def break_stealth!(battle)
391
393
  entity_state = battle.entity_state_for(self)
394
+ return unless entity_state
395
+
392
396
  entity_state[:statuses].delete(:hiding)
393
397
  entity_state[:stealth] = 0
394
398
  end
@@ -410,6 +414,17 @@ module Natural20
410
414
  entity_state[:statuses]&.include?(:dodge)
411
415
  end
412
416
 
417
+ def has_spells?
418
+ return false unless @properties[:prepared_spells]
419
+
420
+ !@properties[:prepared_spells].empty?
421
+ end
422
+
423
+ def ranged_spell_attack!(battle, spell, advantage: false, disadvantage: false)
424
+ DieRoll.roll("1d20+#{spell_attack_modifier}", description: t('dice_roll.ranged_spell_attack', spell: spell),
425
+ entity: self, battle: battle, advantage: advantage, disadvantage: disadvantage)
426
+ end
427
+
413
428
  def hiding?(battle)
414
429
  entity_state = battle.entity_state_for(self)
415
430
  return false unless entity_state
@@ -473,6 +488,10 @@ module Natural20
473
488
  grappled? ? 0 : battle.entity_state_for(self)[:movement]
474
489
  end
475
490
 
491
+ def available_spells
492
+ []
493
+ end
494
+
476
495
  def speed
477
496
  @properties[:speed]
478
497
  end
@@ -587,7 +606,9 @@ module Natural20
587
606
  SKILL_AND_ABILITY_MAP.each do |ability, skills|
588
607
  skills.each do |skill|
589
608
  define_method("#{skill}_mod") do
590
- return @properties[:skills][skill.to_sym] if npc? && @properties[:skills] && @properties[:skills][skill.to_sym]
609
+ if npc? && @properties[:skills] && @properties[:skills][skill.to_sym]
610
+ return @properties[:skills][skill.to_sym]
611
+ end
591
612
 
592
613
  ability_mod = case ability.to_sym
593
614
  when :dex
@@ -797,10 +818,18 @@ module Natural20
797
818
  # Equips an item
798
819
  # @param item_name [String,Symbol]
799
820
  def equip(item_name, ignore_inventory: false)
800
- return @properties[:equipped] << item_name.to_s if ignore_inventory
821
+ @properties[:equipped] ||= []
822
+ if ignore_inventory
823
+ @properties[:equipped] << item_name.to_s
824
+ resolve_trigger(:equip)
825
+ return
826
+ end
801
827
 
802
828
  item = deduct_item(item_name)
803
- @properties[:equipped] << item_name.to_s if item
829
+ if item
830
+ @properties[:equipped] << item_name.to_s
831
+ resolve_trigger(:equip)
832
+ end
804
833
  end
805
834
 
806
835
  # Checks if item can be equipped
@@ -845,6 +874,10 @@ module Natural20
845
874
  true
846
875
  end
847
876
 
877
+ def wearing_armor?
878
+ !!equipped_items.detect { |t| %w[armor shield].include?(t[:type]) }
879
+ end
880
+
848
881
  def hand_slots_required(item)
849
882
  return 0.0 if item.type == 'armor'
850
883
 
@@ -1055,7 +1088,7 @@ module Natural20
1055
1088
  return true if weapon[:name] == 'Unarmed Attack'
1056
1089
 
1057
1090
  @properties[:weapon_proficiencies]&.detect do |prof|
1058
- weapon[:proficiency_type]&.include?(prof)
1091
+ weapon[:proficiency_type]&.include?(prof) || weapon[:proficiency_type]&.include?(weapon[:name].underscore)
1059
1092
  end
1060
1093
  end
1061
1094
 
@@ -1139,19 +1172,145 @@ module Natural20
1139
1172
  description: t("dice_roll.#{save_type}_saving_throw"))
1140
1173
  end
1141
1174
 
1175
+ def active_effects
1176
+ @effects.values.flatten.reject do |effect|
1177
+ effect[:expiration] && effect[:expiration] <= @session.game_time
1178
+ end.uniq
1179
+ end
1180
+
1181
+ def register_effect(effect_type, handler, method_name = nil, effect: nil, source: nil, duration: nil)
1182
+ @effects[effect_type.to_sym] ||= []
1183
+ effect_descriptor = {
1184
+ handler: handler,
1185
+ method: method_name.nil? ? effect_type : method_name,
1186
+ effect: effect,
1187
+ source: source
1188
+ }
1189
+ effect_descriptor[:expiration] = @session.game_time + duration.to_i
1190
+ @effects[effect_type.to_sym] << effect_descriptor
1191
+ end
1192
+
1193
+ def register_event_hook(event_type, handler, method_name = nil, source: nil, effect: nil, duration: nil)
1194
+ @entity_event_hooks[event_type.to_sym] ||= []
1195
+ event_hook_descriptor = {
1196
+ handler: handler,
1197
+ method: method_name.nil? ? event_type : method_name,
1198
+ effect: effect,
1199
+ source: source
1200
+ }
1201
+ event_hook_descriptor[:expiration] = @session.game_time + duration.to_i if duration
1202
+ @entity_event_hooks[event_type.to_sym] << event_hook_descriptor
1203
+ end
1204
+
1205
+ def spell_slots(_level)
1206
+ 0
1207
+ end
1208
+
1209
+ def max_spell_slots(_level)
1210
+ 0
1211
+ end
1212
+
1213
+ def dismiss_effect!(effect)
1214
+ dismiss_count = 0
1215
+ effect.source.casted_effects.reject! { |f| f[:effect] == effect }
1216
+
1217
+ @effects = @effects.map do |k, value|
1218
+ delete_effects = value.select do |f|
1219
+ f[:effect] == effect
1220
+ end
1221
+ dismiss_count += delete_effects.size
1222
+ [k, value - delete_effects]
1223
+ end.to_h
1224
+
1225
+ @entity_event_hooks = @entity_event_hooks.map do |k, value|
1226
+ delete_hooks = value.select do |f|
1227
+ f[:effect] == effect
1228
+ end
1229
+ dismiss_count += delete_hooks.size
1230
+ [k, value - delete_hooks]
1231
+ end.to_h
1232
+
1233
+ dismiss_count
1234
+ end
1235
+
1236
+ def add_casted_effect(effect)
1237
+ @casted_effects << effect
1238
+ end
1239
+
1240
+ def has_spell_effect?(spell)
1241
+ active_effects = @effects.values.flatten.reject do |effect|
1242
+ effect[:expiration] && effect[:expiration] <= @session.game_time
1243
+ end
1244
+ !!active_effects.detect { |effect|
1245
+ effect[:effect].id.to_sym == spell.to_sym
1246
+ }
1247
+ end
1248
+
1142
1249
  protected
1143
1250
 
1251
+ def cleanup_effects
1252
+ @effects = @effects.map do |k, value|
1253
+ delete_effects = value.select do |f|
1254
+ f[:expiration] && f[:expiration] <= @session.game_time
1255
+ end
1256
+ [k, value - delete_effects]
1257
+ end.to_h
1258
+
1259
+ @entity_event_hooks = @entity_event_hooks.map do |k, value|
1260
+ delete_hooks = value.select do |f|
1261
+ f[:expiration] && f[:expiration] <= @session.game_time
1262
+ end
1263
+ [k, value - delete_hooks]
1264
+ end.to_h
1265
+
1266
+ @casted_effects = @casted_effects.select do |f|
1267
+ f[:expiration].blank? || f[:expiration] > @session.game_time
1268
+ end
1269
+ end
1270
+
1271
+ def has_effect?(effect_type)
1272
+ return false unless @effects.key?(effect_type.to_sym)
1273
+ return false if @effects[effect_type.to_sym].empty?
1274
+
1275
+ active_effects = @effects[effect_type.to_sym].reject do |effect|
1276
+ effect[:expiration] && effect[:expiration] <= @session.game_time
1277
+ end
1278
+
1279
+ !active_effects.empty?
1280
+ end
1281
+
1282
+ def eval_effect(effect_type, opts = {})
1283
+ active_effect = @effects[effect_type.to_sym].reject do |effect|
1284
+ effect[:expiration] && effect[:expiration] <= @session.game_time
1285
+ end.last
1286
+
1287
+ active_effect[:handler].send(active_effect[:method], self, opts.merge(effect: active_effect[:effect])) if active_effect
1288
+ end
1289
+
1290
+ def resolve_trigger(event_type)
1291
+ return unless @entity_event_hooks[event_type.to_sym]
1292
+
1293
+ active_hook = @entity_event_hooks[event_type.to_sym].reject do |effect|
1294
+ effect[:expiration] && effect[:expiration] <= @session.game_time
1295
+ end.last
1296
+
1297
+ active_hook[:handler].send(active_hook[:method], self, effect: active_hook[:effect]) if active_hook
1298
+ end
1299
+
1144
1300
  # Localization helper
1145
1301
  # @param token [Symbol, String]
1146
1302
  # @param options [Hash]
1147
1303
  # @return [String]
1148
1304
  def t(token, options = {})
1149
- I18n.t(token, options)
1305
+ I18n.t(token, **options)
1150
1306
  end
1151
1307
 
1152
1308
  def setup_attributes
1153
1309
  @death_saves = 0
1154
1310
  @death_fails = 0
1311
+ @entity_event_hooks = {}
1312
+ @effects = {}
1313
+ @casted_effects = []
1155
1314
  end
1156
1315
 
1157
1316
  def on_take_damage(battle, _damage_params)
@@ -33,15 +33,13 @@ class SecondWindAction < Natural20::Action
33
33
  self
34
34
  end
35
35
 
36
- def apply!(battle)
37
- @result.each do |item|
38
- case (item[:type])
39
- when :second_wind
40
- Natural20::EventManager.received_event(action: self.class, source: item[:source], roll: item[:roll],
41
- event: :second_wind)
42
- item[:source].second_wind!(item[:roll].result)
43
- battle.entity_state_for(item[:source])[:bonus_action] -= 1
44
- end
36
+ def self.apply!(battle, item)
37
+ case (item[:type])
38
+ when :second_wind
39
+ Natural20::EventManager.received_event(action: self.class, source: item[:source], roll: item[:roll],
40
+ event: :second_wind)
41
+ item[:source].second_wind!(item[:roll].result)
42
+ battle.entity_state_for(item[:source])[:bonus_action] -= 1
45
43
  end
46
44
  end
47
45
 
@@ -1,5 +1,5 @@
1
1
  # typed: false
2
- require "natural_20/concerns/fighter_actions/second_wind_action"
2
+ require 'natural_20/concerns/fighter_actions/second_wind_action'
3
3
 
4
4
  module Natural20::FighterClass
5
5
  attr_accessor :fighter_level, :second_wind_count
@@ -29,7 +29,7 @@ module Natural20::FighterClass
29
29
  end
30
30
 
31
31
  # hooks for the fighter class during a short rest
32
- def short_rest_for_fighter
32
+ def short_rest_for_fighter(_battle)
33
33
  @second_wind_count = 1
34
34
  end
35
35
  end
@@ -0,0 +1,33 @@
1
+ module Natural20::SpellAttackHelper
2
+ include Natural20::Cover
3
+ include Natural20::AttackHelper
4
+
5
+ def evaluate_spell_attack(battle, entity, target, spell_properties)
6
+ # DnD 5e advantage/disadvantage checks
7
+ advantage_mod, adv_info = target_advantage_condition(battle, entity, target, spell_properties)
8
+
9
+ attack_roll = entity.ranged_spell_attack!(battle, spell_properties[:name], advantage: advantage_mod.positive?,
10
+ disadvantage: advantage_mod.negative?)
11
+
12
+ target_ac, _cover_ac = effective_ac(
13
+ battle, target
14
+ )
15
+
16
+ force_miss = after_attack_roll_hook(battle, target,
17
+ source, attack_roll, target_ac, spell: spell_properties)
18
+ if !force_miss
19
+ cover_ac_adjustments = 0
20
+ hit = if attack_roll.nat_20?
21
+ true
22
+ elsif attack_roll.nat_1?
23
+ false
24
+ else
25
+ target_ac, cover_ac_adjustments = effective_ac(battle, target)
26
+ attack_roll.result >= target_ac
27
+ end
28
+ else
29
+ hit = false
30
+ end
31
+ [hit, attack_roll, advantage_mod, cover_ac_adjustments]
32
+ end
33
+ end
@@ -0,0 +1,86 @@
1
+ # typed: false
2
+ module Natural20::WizardClass
3
+ WIZARD_SPELL_SLOT_TABLE =
4
+ [
5
+ # cantrips, 1st, 2nd, 3rd ... etc
6
+ [3, 2], # 1
7
+ [3, 3], # 2
8
+ [3, 4, 2], # 3
9
+ [4, 4, 3], # 4
10
+ [4, 4, 3, 2], # 5
11
+ [4, 4, 3, 3], # 6
12
+ [4, 4, 3, 3, 1], # 7
13
+ [4, 4, 3, 3, 2], # 8
14
+ [4, 4, 3, 3, 3, 1], # 9
15
+ [5, 4, 3, 3, 3, 2], # 10
16
+ [5, 4, 3, 3, 3, 2, 1], # 11
17
+ [5, 4, 3, 3, 3, 2, 1], # 12
18
+ [5, 4, 3, 3, 3, 2, 1, 1], # 13
19
+ [5, 4, 3, 3, 3, 2, 1, 1], # 14
20
+ [5, 4, 3, 3, 3, 2, 1, 1, 1], # 15
21
+ [5, 4, 3, 3, 3, 2, 1, 1, 1], # 16
22
+ [5, 4, 3, 3, 3, 2, 1, 1, 1, 1], # 17
23
+ [5, 4, 3, 3, 3, 3, 1, 1, 1, 1], # 18
24
+ [5, 4, 3, 3, 3, 3, 2, 1, 1, 1], # 19
25
+ [5, 4, 3, 3, 3, 3, 2, 2, 1, 1] # 20
26
+ ].freeze
27
+
28
+ attr_accessor :wizard_level, :wizard_spell_slots, :arcane_recovery
29
+
30
+ def initialize_wizard
31
+ @spell_slots[:wizard] = reset_spell_slots
32
+ @arcane_recovery = 1
33
+ end
34
+
35
+ def spell_attack_modifier
36
+ proficiency_bonus + int_mod
37
+ end
38
+
39
+ def special_actions_for_wizard(_session, _battle)
40
+ []
41
+ end
42
+
43
+ # @param battle [Natural20::Battle]
44
+ def short_rest_for_wizard(battle)
45
+ if @arcane_recovery.positive?
46
+ controller = battle.controller_for(self)
47
+ if controller && controller.respond_to?(:arcane_recovery_ui)
48
+ max_sum = (wizard_level / 2).ceil
49
+ loop do
50
+ current_sum = 0
51
+ avail_levels = WIZARD_SPELL_SLOT_TABLE[wizard_level - 1].each_with_index.map do |slots, index|
52
+ next if index.zero?
53
+ next if index >= 6 # none of the spell sltos can be 6 or higher
54
+
55
+ next if @spell_slots[:wizard][index] >= slots
56
+ next if current_sum > max_sum
57
+
58
+ current_sum += index
59
+ index
60
+ end.compact
61
+
62
+ break if avail_levels.empty?
63
+
64
+ level = controller.arcane_recovery_ui(self, avail_levels)
65
+ break if level.nil?
66
+
67
+ @spell_slots[:wizard][level] += 1
68
+ @arcane_recovery = 0
69
+ max_sum -= level
70
+ end
71
+ end
72
+ end
73
+ end
74
+
75
+ def max_slots_for_wizard(level)
76
+ WIZARD_SPELL_SLOT_TABLE[wizard_level - 1][level] || 0
77
+ end
78
+
79
+ protected
80
+
81
+ def reset_spell_slots
82
+ WIZARD_SPELL_SLOT_TABLE[wizard_level - 1].each_with_index.map do |slots, index|
83
+ [index, slots]
84
+ end.to_h
85
+ end
86
+ end