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.
- checksums.yaml +4 -4
- data/.gitignore +1 -0
- data/CHANGELOG.md +13 -0
- data/README.md +1 -0
- data/bin/nat20 +2 -1
- data/bin/nat20.cmd +0 -0
- data/char_classes/wizard.yml +89 -0
- data/characters/high_elf_mage.yml +27 -0
- data/fixtures/battle_sim_objects.yml +2 -2
- data/fixtures/high_elf_mage.yml +28 -0
- data/fixtures/large_map.yml +63 -0
- data/game.yml +2 -2
- data/items/equipment.yml +30 -0
- data/items/objects.yml +33 -29
- data/items/spells.yml +58 -0
- data/items/weapons.yml +78 -18
- data/lib/CHANGELOG.md +0 -0
- data/lib/natural_20.rb +9 -0
- data/lib/natural_20/actions/action.rb +2 -2
- data/lib/natural_20/actions/attack_action.rb +76 -67
- data/lib/natural_20/actions/concerns/action_damage.rb +3 -1
- data/lib/natural_20/actions/dash_action.rb +7 -10
- data/lib/natural_20/actions/disengage_action.rb +11 -12
- data/lib/natural_20/actions/dodge_action.rb +7 -8
- data/lib/natural_20/actions/escape_grapple_action.rb +16 -18
- data/lib/natural_20/actions/first_aid_action.rb +14 -16
- data/lib/natural_20/actions/grapple_action.rb +24 -28
- data/lib/natural_20/actions/ground_interact_action.rb +1 -3
- data/lib/natural_20/actions/help_action.rb +13 -16
- data/lib/natural_20/actions/hide_action.rb +7 -9
- data/lib/natural_20/actions/interact_action.rb +12 -14
- data/lib/natural_20/actions/look_action.rb +14 -15
- data/lib/natural_20/actions/move_action.rb +9 -9
- data/lib/natural_20/actions/multiattack_action.rb +8 -9
- data/lib/natural_20/actions/prone_action.rb +4 -6
- data/lib/natural_20/actions/short_rest_action.rb +7 -8
- data/lib/natural_20/actions/shove_action.rb +20 -24
- data/lib/natural_20/actions/spell_action.rb +89 -0
- data/lib/natural_20/actions/stand_action.rb +5 -7
- data/lib/natural_20/actions/use_item_action.rb +7 -9
- data/lib/natural_20/ai_controller/standard.rb +1 -1
- data/lib/natural_20/battle.rb +8 -3
- data/lib/natural_20/cli/action_ui.rb +180 -0
- data/lib/natural_20/cli/builder/fighter_builder.rb +1 -1
- data/lib/natural_20/cli/builder/rogue_builder.rb +10 -10
- data/lib/natural_20/cli/builder/wizard_builder.rb +77 -0
- data/lib/natural_20/cli/character_builder.rb +9 -4
- data/lib/natural_20/cli/commandline_ui.rb +55 -162
- data/lib/natural_20/cli/inventory_ui.rb +4 -0
- data/lib/natural_20/cli/map_renderer.rb +7 -1
- data/lib/natural_20/concerns/attack_helper.rb +53 -0
- data/lib/natural_20/concerns/entity.rb +170 -11
- data/lib/natural_20/concerns/fighter_actions/second_wind_action.rb +7 -9
- data/lib/natural_20/concerns/fighter_class.rb +2 -2
- data/lib/natural_20/concerns/spell_attack_helper.rb +33 -0
- data/lib/natural_20/concerns/wizard_class.rb +86 -0
- data/lib/natural_20/die_roll.rb +2 -2
- data/lib/natural_20/event_manager.rb +50 -44
- data/lib/natural_20/item_library/base_item.rb +1 -1
- data/lib/natural_20/npc.rb +4 -0
- data/lib/natural_20/player_character.rb +75 -12
- data/lib/natural_20/session.rb +14 -1
- data/lib/natural_20/spell_library/firebolt.rb +72 -0
- data/lib/natural_20/spell_library/mage_armor.rb +67 -0
- data/lib/natural_20/spell_library/mage_hand.rb +2 -0
- data/lib/natural_20/spell_library/magic_missile.rb +67 -0
- data/lib/natural_20/spell_library/shield.rb +69 -0
- data/lib/natural_20/spell_library/spell.rb +31 -0
- data/lib/natural_20/utils/weapons.rb +8 -6
- data/lib/natural_20/version.rb +1 -1
- data/locales/en.yml +44 -8
- data/maps/game_map.yml +12 -2
- 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)
|
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]
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
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
|
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
|