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