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