natural_20 0.1.1 → 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
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