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
@@ -9,7 +9,7 @@ class MultiattackAction < Natural20::Action
9
9
  def build_map
10
10
  OpenStruct.new({
11
11
  param: nil,
12
- next: -> { self },
12
+ next: -> { self }
13
13
  })
14
14
  end
15
15
 
@@ -22,20 +22,19 @@ class MultiattackAction < Natural20::Action
22
22
  @result = [{
23
23
  source: @source,
24
24
  type: :multiattack,
25
- battle: opts[:battle],
25
+ battle: opts[:battle]
26
26
  }]
27
27
  self
28
28
  end
29
29
 
30
30
  # @param battle [Natural20::Battle]
31
- def apply!(battle)
32
- @result.each do |item|
33
- case (item[:type])
34
- when :multiattack
35
- @total_attacks += 2
36
- end
37
-
31
+ def self.apply!(battle, item)
32
+ case (item[:type])
33
+ when :multiattack
34
+ @total_attacks += 2
38
35
  battle.consume!(:action, 1)
39
36
  end
37
+
38
+
40
39
  end
41
40
  end
@@ -27,12 +27,10 @@ class ProneAction < Natural20::Action
27
27
  self
28
28
  end
29
29
 
30
- def apply!(_battle)
31
- @result.each do |item|
32
- case (item[:type])
33
- when :prone
34
- item[:source].prone!
35
- end
30
+ def apply!(_battle, item)
31
+ case (item[:type])
32
+ when :prone
33
+ item[:source].prone!
36
34
  end
37
35
  end
38
36
  end
@@ -39,15 +39,14 @@ class ShortRestAction < Natural20::Action
39
39
  end
40
40
 
41
41
  # @param battle [Natural20::Battle]
42
- def apply!(battle)
43
- @result.each do |item|
44
- case (item[:type])
45
- when :short_rest
46
- Natural20::EventManager.received_event({ source: item[:source], event: :short_rest, targets: item[:targets] })
47
- item[:targets].each do |entity|
48
- entity.short_rest!(battle, prompt: true)
49
- end
42
+ def self.apply!(battle, item)
43
+ case (item[:type])
44
+ when :short_rest
45
+ Natural20::EventManager.received_event({ source: item[:source], event: :short_rest, targets: item[:targets] })
46
+ item[:targets].each do |entity|
47
+ entity.short_rest!(battle, prompt: true)
50
48
  end
49
+ battle.session.increment_game_time!(60 * 60) # increment by an hour
51
50
  end
52
51
  end
53
52
  end
@@ -107,33 +107,29 @@ class ShoveAction < Natural20::Action
107
107
  end
108
108
  end
109
109
 
110
- def apply!(battle)
111
- @result.each do |item|
112
- case (item[:type])
113
- when :damage
114
- damage_event(item, battle)
115
- when :shove
116
- if item[:success]
117
- if item[:knock_prone]
118
- item[:target].prone!
119
- elsif item[:shove_loc]
120
- item[:battle].map.move_to!(item[:target], *item[:shove_loc], battle)
121
- end
122
-
123
- Natural20::EventManager.received_event(event: :shove_success,
124
- knock_prone: item[:knock_prone],
125
- target: item[:target], source: item[:source],
126
- source_roll: item[:source_roll],
127
- target_roll: item[:target_roll])
128
- else
129
- Natural20::EventManager.received_event(event: :shove_failure,
130
- target: item[:target], source: item[:source],
131
- source_roll: item[:source_roll],
132
- target_roll: item[:target_roll])
110
+ def self.apply!(battle, item)
111
+ case (item[:type])
112
+ when :shove
113
+ if item[:success]
114
+ if item[:knock_prone]
115
+ item[:target].prone!
116
+ elsif item[:shove_loc]
117
+ item[:battle].map.move_to!(item[:target], *item[:shove_loc], battle)
133
118
  end
134
119
 
135
- battle.entity_state_for(item[:source])[:action] -= 1
120
+ Natural20::EventManager.received_event(event: :shove_success,
121
+ knock_prone: item[:knock_prone],
122
+ target: item[:target], source: item[:source],
123
+ source_roll: item[:source_roll],
124
+ target_roll: item[:target_roll])
125
+ else
126
+ Natural20::EventManager.received_event(event: :shove_failure,
127
+ target: item[:target], source: item[:source],
128
+ source_roll: item[:source_roll],
129
+ target_roll: item[:target_roll])
136
130
  end
131
+
132
+ battle.entity_state_for(item[:source])[:action] -= 1
137
133
  end
138
134
  end
139
135
  end
@@ -0,0 +1,89 @@
1
+ class SpellAction < Natural20::Action
2
+ extend Natural20::ActionDamage
3
+
4
+ attr_accessor :target, :spell_action, :spell, :other_params, :at_level
5
+
6
+ def self.can?(entity, battle, options = {})
7
+ return false unless entity.has_spells?
8
+
9
+ battle.nil? || !battle.ongoing? || can_cast?(entity, battle, options[:spell])
10
+ end
11
+
12
+ # @param battle [Natural20::Battle]
13
+ # @param spell [Symbol]
14
+ def self.can_cast?(entity, battle, spell)
15
+ return true unless spell
16
+
17
+ spell_details = battle.session.load_spell(spell)
18
+ amt, resource = spell_details[:casting_time].split(':')
19
+
20
+ return true if resource == ('action') && battle.total_actions(entity).positive?
21
+
22
+ false
23
+ end
24
+
25
+ def self.build(session, source)
26
+ action = SpellAction.new(session, source, :spell)
27
+ action.build_map
28
+ end
29
+
30
+ def build_map
31
+ OpenStruct.new({
32
+ action: self,
33
+ param: [
34
+ {
35
+ type: :select_spell
36
+ }
37
+ ],
38
+ next: lambda { |spell_choice|
39
+ spell, at_level = spell_choice
40
+ @spell = session.load_spell(spell)
41
+ raise "spell not found #{spell}" unless @spell
42
+
43
+ self.at_level = at_level
44
+ @spell_action = @spell[:spell_class].constantize.new(@source, spell, @spell)
45
+ @spell_action.build_map(self)
46
+ }
47
+ })
48
+ end
49
+
50
+ def resolve(_session, _map = nil, opts = {})
51
+ battle = opts[:battle]
52
+ @result = spell_action.resolve(@source, battle, self)
53
+ self
54
+ end
55
+
56
+ def self.apply!(battle, item)
57
+ Natural20::Spell.descendants.each do |klass|
58
+ klass.apply!(battle, item)
59
+ end
60
+ case item[:type]
61
+ when :spell_damage
62
+ damage_event(item, battle)
63
+ consume_resource(battle, item)
64
+ when :spell_miss
65
+ consume_resource(battle, item)
66
+ Natural20::EventManager.received_event({ attack_roll: item[:attack_roll],
67
+ attack_name: item[:attack_name],
68
+ advantage_mod: item[:advantage_mod],
69
+ as_reaction: !!item[:as_reaction],
70
+ adv_info: item[:adv_info],
71
+ source: item[:source], target: item[:target], event: :miss })
72
+ end
73
+ end
74
+
75
+ # @param battle [Natural20::Battle]
76
+ # @param item [Hash]
77
+ def self.consume_resource(battle, item)
78
+ amt, resource = item.dig(:spell, :casting_time).split(':')
79
+ spell_level = item.dig(:spell, :level)
80
+ case resource
81
+ when 'action'
82
+ battle.consume(item[:source], :action)
83
+ when 'reaction'
84
+ battle.consume(item[:source], :reaction)
85
+ end
86
+
87
+ item[:source].consume_spell_slot!(spell_level) if spell_level.positive?
88
+ end
89
+ end
@@ -29,13 +29,11 @@ class StandAction < Natural20::Action
29
29
  self
30
30
  end
31
31
 
32
- def apply!(battle)
33
- @result.each do |item|
34
- case (item[:type])
35
- when :stand
36
- item[:source].stand!
37
- battle.entity_state_for(item[:source])[:movement] -= (item[:source].speed / 2).floor
38
- end
32
+ def apply!(battle, item)
33
+ case (item[:type])
34
+ when :stand
35
+ item[:source].stand!
36
+ battle.consume(item[:source], :movement, (item[:source].speed / 2).floor)
39
37
  end
40
38
  end
41
39
 
@@ -43,15 +43,13 @@ class UseItemAction < Natural20::Action
43
43
  self
44
44
  end
45
45
 
46
- def apply!(battle)
47
- @result.each do |item|
48
- case (item[:type])
49
- when :use_item
50
- Natural20::EventManager.received_event({ event: :use_item, source: item[:source], item: item[:item] })
51
- item[:item].use!(item[:target], item)
52
- item[:source].deduct_item(item[:item].name, 1) if item[:item].consumable?
53
- battle.entity_state_for(item[:source])[:action] -= 1 if battle
54
- end
46
+ def self.apply!(battle, item)
47
+ case item[:type]
48
+ when :use_item
49
+ Natural20::EventManager.received_event({ event: :use_item, source: item[:source], item: item[:item] })
50
+ item[:item].use!(item[:target], item)
51
+ item[:source].deduct_item(item[:item].name, 1) if item[:item].consumable?
52
+ battle.entity_state_for(item[:source])[:action] -= 1 if battle
55
53
  end
56
54
  end
57
55
  end
@@ -205,7 +205,7 @@ module AiController
205
205
  if has_ranged_weapon?(entity)
206
206
  range_weight = 2.0
207
207
  else
208
- melee_weight = 2.0
208
+ melee_weight = 2.1
209
209
  end
210
210
 
211
211
  # defense_weight = 2.0 if (entity.hp / entity.max_hp) < 0.25
@@ -337,7 +337,7 @@ module Natural20
337
337
  end
338
338
 
339
339
  # consume action resource and return if something changed
340
- def consume!(entity, resource, qty)
340
+ def consume!(entity, resource, qty = 1)
341
341
  current_qty = entity_state_for(entity)[resource.to_sym]
342
342
  new_qty = [0, current_qty - qty].max
343
343
  entity_state_for(entity)[resource.to_sym] = new_qty
@@ -505,7 +505,11 @@ module Natural20
505
505
  return if action.nil?
506
506
 
507
507
  # check_action_serialization(action)
508
- action.apply!(self)
508
+ action.result.each do |item|
509
+ Natural20::Action.descendants.each do |klass|
510
+ klass.apply!(self, item)
511
+ end
512
+ end
509
513
 
510
514
  case action.action_type
511
515
  when :move
@@ -528,6 +532,7 @@ module Natural20
528
532
  # @param resouce [Symbol]
529
533
  def consume(entity, resource, qty = 1)
530
534
  raise 'invalid resource' unless %i[action reaction bonus_action movement].include?(resource.to_sym)
535
+ return unless entity_state_for(entity)
531
536
 
532
537
  entity_state_for(entity)[resource.to_sym] = [0, entity_state_for(entity)[resource.to_sym] - qty].max
533
538
  end
@@ -535,7 +540,7 @@ module Natural20
535
540
  protected
536
541
 
537
542
  def t(key, options = {})
538
- I18n.t(key, options)
543
+ I18n.t(key, **options)
539
544
  end
540
545
 
541
546
  def check_action_serialization(action)
@@ -0,0 +1,180 @@
1
+ module Natural20::ActionUI
2
+ TTY_PROMPT_PER_PAGE = 20
3
+
4
+ # Show action UI
5
+ # @param action [Natural20::Action]
6
+ # @param entity [Entity]
7
+ def action_ui(action, entity)
8
+ return :stop if action == :stop
9
+
10
+ cont = action.build_map
11
+ loop do
12
+ param = cont.param&.map do |p|
13
+ case (p[:type])
14
+ when :look
15
+ self
16
+ when :movement
17
+ move_path, jump_index = move_ui(entity, p)
18
+ return nil if move_path.nil?
19
+
20
+ [move_path, jump_index]
21
+ when :target, :select_target
22
+ targets = attack_ui(entity, action, p)
23
+ return nil if targets.nil? || targets.empty?
24
+
25
+ p[:num] > 1 ? targets : targets.first
26
+ when :select_spell
27
+ spell_slots_ui(entity)
28
+
29
+ spell = prompt.select("#{entity.name} cast Spell", per_page: TTY_PROMPT_PER_PAGE) do |q|
30
+ spell_choice(entity, battle, q)
31
+ q.choice t(:back).colorize(:blue), :back
32
+ end
33
+
34
+ return nil if spell == :back
35
+
36
+ spell
37
+ when :select_weapon
38
+ action.using || action.npc_action
39
+ when :select_item
40
+ item = prompt.select("#{entity.name} use item", per_page: TTY_PROMPT_PER_PAGE) do |menu|
41
+ entity.usable_items.each do |d|
42
+ if d[:consumable]
43
+ menu.choice "#{d[:label].colorize(:blue)} (#{d[:qty]})", d[:name]
44
+ else
45
+ menu.choice d[:label].colorize(:blue).to_s, d[:name]
46
+ end
47
+ end
48
+ menu.choice t(:back).colorize(:blue), :back
49
+ end
50
+
51
+ return nil if item == :back
52
+
53
+ item
54
+ when :select_ground_items
55
+ selected_items = prompt.multi_select("Items on the ground around #{entity.name}") do |menu|
56
+ map.items_on_the_ground(entity).each do |ground_item|
57
+ ground, items = ground_item
58
+ items.each do |t|
59
+ item_label = t("object.#{t.label}", default: t.label)
60
+ menu.choice t('inventory.inventory_items', name: item_label, qty: t.qty), [ground, t]
61
+ end
62
+ end
63
+ end
64
+
65
+ return nil if selected_items.empty?
66
+
67
+ item_selection = {}
68
+ selected_items.each do |s_item|
69
+ ground, item = s_item
70
+
71
+ qty = how_many?(item)
72
+ item_selection[ground] ||= []
73
+ item_selection[ground] << [item, qty]
74
+ end
75
+
76
+ item_selection.map do |k, v|
77
+ [k, v]
78
+ end
79
+ when :select_object
80
+ target_objects = entity.usable_objects(map, battle)
81
+ item = prompt.select("#{entity.name} interact with") do |menu|
82
+ target_objects.each do |d|
83
+ menu.choice d.name.humanize.to_s, d
84
+ end
85
+ menu.choice t(:manual_target), :manual_target
86
+ menu.choice t(:back).colorize(:blue), :back
87
+ end
88
+
89
+ return nil if item == :back
90
+
91
+ if item == :manual_target
92
+ item = target_ui(entity, num_select: 1, validation: lambda { |selected|
93
+ selected_entities = map.thing_at(*selected)
94
+
95
+ return false if selected_entities.empty?
96
+
97
+ selected_entities.detect do |selected_entity|
98
+ target_objects.include?(selected_entity)
99
+ end
100
+ }).first
101
+ end
102
+
103
+ item
104
+ when :select_items
105
+ selected_items = prompt.multi_select(p[:label], per_page: TTY_PROMPT_PER_PAGE) do |menu|
106
+ p[:items].each do |m|
107
+ item_label = t("object.#{m.label}", default: m.label)
108
+ if m.try(:equipped)
109
+ menu.choice t('inventory.equiped_items', name: item_label), m
110
+ else
111
+ menu.choice t('inventory.inventory_items', name: item_label, qty: m.qty), m
112
+ end
113
+ end
114
+ menu.choice t(:back).colorize(:blue), :back
115
+ end
116
+
117
+ return nil if selected_items.include?(:back)
118
+
119
+ selected_items = selected_items.map do |m|
120
+ count = how_many?(m)
121
+ [m, count]
122
+ end
123
+ selected_items
124
+ when :interact
125
+ object_action = prompt.select("#{entity.name} will") do |menu|
126
+ interactions = p[:target].available_interactions(entity)
127
+ class_key = p[:target].class.to_s
128
+ if interactions.is_a?(Array)
129
+ interactions.each do |k|
130
+ menu.choice t(:"object.#{class_key}.#{k}", default: k.to_s.humanize), k
131
+ end
132
+ else
133
+ interactions.each do |k, options|
134
+ label = options[:label] || t(:"object.#{class_key}.#{k}", default: k.to_s.humanize)
135
+ if options[:disabled]
136
+ menu.choice label, k, disabled: options[:disabled_text]
137
+ else
138
+ menu.choice label, k
139
+ end
140
+ end
141
+ end
142
+ menu.choice 'Back', :back
143
+ end
144
+
145
+ return nil if item == :back
146
+
147
+ object_action
148
+ when :show_inventory
149
+ inventory_ui(entity)
150
+ else
151
+ raise "unknown #{p[:type]}"
152
+ end
153
+ end
154
+ cont = cont.next.call(*param)
155
+ break if param.nil?
156
+ end
157
+ @action = cont
158
+ end
159
+
160
+ def spell_choice(entity, battle, menu)
161
+ entity.available_spells(battle).each do |k, details|
162
+ available_levels = if details[:higher_level]
163
+ (details[:level]..9).select { |lvl| entity.max_spell_slots(lvl).positive? }
164
+ else
165
+ [details[:level]]
166
+ end
167
+ available_levels.each do |spell_level|
168
+ level_str = spell_level.zero? ? 'cantrip' : "lvl. #{spell_level}"
169
+ choice_label = t(:"action.spell_choice", spell: t("spell.#{k}"), level: level_str,
170
+ description: details[:description])
171
+ if details[:disabled].empty?
172
+ menu.choice(choice_label, [k, spell_level])
173
+ else
174
+ disable_reason = details[:disabled].map { |d| t("spells.disabled.#{d}") }.join(', ')
175
+ menu.choice(choice_label, k, disabled: disable_reason)
176
+ end
177
+ end
178
+ end
179
+ end
180
+ end