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
@@ -1,5 +1,5 @@
1
1
  module Natural20::FighterBuilder
2
- def fighter_builder
2
+ def fighter_builder(build_values)
3
3
  @class_values ||= {
4
4
  attributes: [],
5
5
  saving_throw_proficiencies: %w[strength constitution],
@@ -1,9 +1,9 @@
1
1
  module Natural20::RogueBuilder
2
- def rogue_builder
2
+ def rogue_builder(_build_values)
3
3
  @class_values ||= {
4
4
  attributes: [],
5
5
  saving_throw_proficiencies: %w[dexterity intelligence],
6
- equipped: ['leather','dagger','dagger'],
6
+ equipped: %w[leather dagger dagger],
7
7
  inventory: [],
8
8
  tools: ['thieves_tools'],
9
9
  expertise: []
@@ -13,7 +13,7 @@ module Natural20::RogueBuilder
13
13
  @values[:skills].each do |skill|
14
14
  q.choice t("builder.skill.#{skill}"), skill
15
15
  end
16
- q.choice t("builder.skill.thieves_tools"), 'thieves_tools'
16
+ q.choice t('builder.skill.thieves_tools'), 'thieves_tools'
17
17
  end
18
18
 
19
19
  starting_equipment = []
@@ -36,17 +36,17 @@ module Natural20::RogueBuilder
36
36
  }
37
37
  when :shortbow_and_quiver
38
38
  @class_values[:inventory] += [{
39
- type: "shortbow",
39
+ type: 'shortbow',
40
40
  qty: 1
41
41
  },
42
- {
43
- type: 'arrows',
44
- qty: 20
45
- }]
42
+ {
43
+ type: 'arrows',
44
+ qty: 20
45
+ }]
46
46
  end
47
47
  end
48
48
 
49
- shortswords = starting_equipment.select { |a| a == :shortword}.size
49
+ shortswords = starting_equipment.select { |a| a == :shortword }.size
50
50
  if shortswords > 0
51
51
  @class_values[:inventory] << {
52
52
  type: 'shortsword',
@@ -59,4 +59,4 @@ module Natural20::RogueBuilder
59
59
 
60
60
  @class_values
61
61
  end
62
- end
62
+ end
@@ -0,0 +1,77 @@
1
+ module Natural20::WizardBuilder
2
+ def wizard_builder(build_values)
3
+ @wizard_info = session.load_class('wizard')
4
+ @class_values ||= {
5
+ attributes: [],
6
+ saving_throw_proficiencies: %w[intelligence wisdom],
7
+ equipped: [],
8
+ inventory: [{
9
+ type: 'spellbook',
10
+ qty: 1
11
+ }],
12
+ prepared_spells: [],
13
+ spellbook: []
14
+ }
15
+
16
+ starting_equipment = []
17
+
18
+ starting_equipment << prompt.select(t('builder.wizard.select_starting_weapon')) do |q|
19
+ q.choice t('object.weapons.quarterstaff'), :quarterstaff
20
+ q.choice t('object.weapons.dagger'), :dagger
21
+ end
22
+
23
+ starting_equipment << prompt.select(t('builder.wizard.select_starting_weapon_2')) do |q|
24
+ q.choice t('object.component_pouch'), :component_pouch
25
+ q.choice t('object.arcane_focus'), :arcane_focus
26
+ end
27
+
28
+ starting_equipment.each do |equip|
29
+ case equip
30
+ when :quarterstaff
31
+ @class_values[:equipped] << 'quarterstaff'
32
+ when :dagger
33
+ @class_values[:equipped] << 'dagger'
34
+ when :component_pouch
35
+ @class_values[:inventory] << {
36
+ type: 'component_pouch',
37
+ qty: 1
38
+ }
39
+ when :arcane_focus
40
+ arcane_focus = prompt.select(t('builder.wizard.select_arcane_focus')) do |q|
41
+ %w[crytal orb rod staff wand].each do |equip|
42
+ q.choice t(:"object.#{equip}"), equip
43
+ end
44
+ end
45
+ @class_values[:inventory] << {
46
+ type: arcane_focus,
47
+ qty: 1
48
+ }
49
+ end
50
+ end
51
+
52
+ @class_values[:prepared_spells] = prompt.multi_select(t('builder.wizard.select_cantrip'), min: 3, max: 3) do |q|
53
+ @wizard_info.dig(:spell_list, :cantrip).each do |cantrip|
54
+ next unless session.load_spell(cantrip)
55
+
56
+ q.choice t(:"spell.#{cantrip}"), cantrip
57
+ end
58
+ end
59
+
60
+ @class_values[:spellbook] = prompt.multi_select(t('builder.wizard.select_spells'), min: 6, max: 6) do |q|
61
+ @wizard_info.dig(:spell_list, :level_1).each do |spell|
62
+ next unless session.load_spell(spell)
63
+
64
+ q.choice t(:"spell.#{spell}"), spell
65
+ end
66
+ end
67
+
68
+ prepared_spells_count = modifier_table(build_values[:ability][:int]) + 1
69
+
70
+ @class_values[:prepared_spells] = prompt.multi_select(t('builder.wizard.select_prepared_spells'), min: prepared_spells_count, max: prepared_spells_count) do |q|
71
+ @class_values[:spellbook].each do |spell|
72
+ q.choice t(:"spell.#{spell}"), spell
73
+ end
74
+ end
75
+ @class_values
76
+ end
77
+ end
@@ -1,9 +1,11 @@
1
1
  require 'natural_20/cli/builder/fighter_builder'
2
2
  require 'natural_20/cli/builder/rogue_builder'
3
+ require 'natural_20/cli/builder/wizard_builder'
3
4
  module Natural20
4
5
  class CharacterBuilder
5
6
  include Natural20::FighterBuilder
6
7
  include Natural20::RogueBuilder
8
+ include Natural20::WizardBuilder
7
9
  include Natural20::InventoryUI
8
10
 
9
11
  attr_reader :session, :battle
@@ -47,9 +49,11 @@ module Natural20
47
49
  end
48
50
  end
49
51
 
50
- @values[:description] = prompt.multiline(t('builder.description')) do |q|
51
- q.default t('buider.default_description')
52
- end.join("\n")
52
+ description = prompt.multiline(t('builder.description')) do |q|
53
+ q.default t('builder.default_description')
54
+ end
55
+
56
+ @values[:description] = description.is_a?(Array) ? description.join("\n") : description
53
57
 
54
58
  races = session.load_races
55
59
  @values[:race] = prompt.select(t('builder.select_race')) do |q|
@@ -140,7 +144,8 @@ module Natural20
140
144
 
141
145
  class_skills_selector
142
146
 
143
- send(:"#{k}_builder")
147
+ send(:"#{k}_builder", @values)
148
+
144
149
  @values.merge!(@class_values)
145
150
  @pc = Natural20::PlayerCharacter.new(session, @values)
146
151
  character_sheet(@pc)
@@ -1,10 +1,12 @@
1
1
  require 'natural_20/cli/inventory_ui'
2
2
  require 'natural_20/cli/character_builder'
3
+ require 'natural_20/cli/action_ui'
3
4
 
4
5
  class CommandlineUI < Natural20::Controller
5
6
  include Natural20::InventoryUI
6
7
  include Natural20::MovementHelper
7
8
  include Natural20::Cover
9
+ include Natural20::ActionUI
8
10
 
9
11
  TTY_PROMPT_PER_PAGE = 20
10
12
  attr_reader :battle, :map, :session, :test_mode
@@ -54,33 +56,37 @@ class CommandlineUI < Natural20::Controller
54
56
  selected_targets = []
55
57
  valid_targets = options[:targets] || battle.valid_targets_for(entity, action, target_types: options[:target_types],
56
58
  range: options[:range], filter: options[:filter])
57
- target = prompt.select("#{entity.name} targets") do |menu|
58
- valid_targets.each do |target|
59
- menu.choice target_name(entity, target, weapon: weapon_details), target
59
+ total_targets = options[:num] || 1
60
+ puts t(:"multiple_targets", total_targets: total_targets) if total_targets > 1
61
+ total_targets.times.each do |index|
62
+ target = prompt.select("Target #{index + 1}: #{entity.name} targets") do |menu|
63
+ valid_targets.each do |t|
64
+ menu.choice target_name(entity, t, weapon: weapon_details), t
65
+ end
66
+ menu.choice 'Manual - Use cursor to select a target instead', :manual
67
+ menu.choice 'Back', nil
60
68
  end
61
- menu.choice 'Manual - Use cursor to select a target instead', :manual
62
- menu.choice 'Back', nil
63
- end
64
69
 
65
- return nil if target == 'Back'
70
+ return nil if target == 'Back'
66
71
 
67
- if target == :manual
68
- valid_targets = options[:targets] || battle.valid_targets_for(entity, action,
69
- target_types: options[:target_types], range: options[:range],
70
- filter: options[:filter],
71
- include_objects: true)
72
- selected_targets = target_ui(entity, weapon: weapon_details, validation: lambda { |selected|
73
- selected_entities = map.thing_at(*selected)
72
+ if target == :manual
73
+ valid_targets = options[:targets] || battle.valid_targets_for(entity, action,
74
+ target_types: options[:target_types], range: options[:range],
75
+ filter: options[:filter],
76
+ include_objects: true)
77
+ selected_targets += target_ui(entity, weapon: weapon_details, validation: lambda { |selected|
78
+ selected_entities = map.thing_at(*selected)
74
79
 
75
- if selected_entities.empty?
76
- return false
77
- end
80
+ if selected_entities.empty?
81
+ return false
82
+ end
83
+
84
+ selected_entities.detect do |selected_entity|
85
+ valid_targets.include?(selected_entity)
86
+ end
87
+ })
88
+ end
78
89
 
79
- selected_entities.detect do |selected_entity|
80
- valid_targets.include?(selected_entity)
81
- end
82
- })
83
- else
84
90
  selected_targets << target
85
91
  end
86
92
 
@@ -184,7 +190,7 @@ class CommandlineUI < Natural20::Controller
184
190
  selected_targets = []
185
191
  targets = selected.flatten.select { |t| t.hp && t.hp.positive? }.flatten.uniq
186
192
 
187
- if targets.size > num_select
193
+ if targets.size > 1
188
194
  loop do
189
195
  target = prompt.select(t('multiple_target_prompt')) do |menu|
190
196
  targets.flatten.uniq.each do |t|
@@ -320,149 +326,34 @@ class CommandlineUI < Natural20::Controller
320
326
  end
321
327
  end
322
328
 
323
- # Show action UI
324
- # @param action [Natural20::Action]
325
- # @param entity [Entity]
326
- def action_ui(action, entity)
327
- return :stop if action == :stop
328
-
329
- cont = action.build_map
330
- loop do
331
- param = cont.param&.map do |p|
332
- case (p[:type])
333
- when :look
334
- self
335
- when :movement
336
- move_path, jump_index = move_ui(entity, p)
337
- return nil if move_path.nil?
338
-
339
- [move_path, jump_index]
340
- when :target, :select_target
341
- targets = attack_ui(entity, action, p)
342
- return nil if targets.nil? || targets.empty?
343
-
344
- targets.first
345
- when :select_weapon
346
- action.using || action.npc_action
347
- when :select_item
348
- item = prompt.select("#{entity.name} use item", per_page: TTY_PROMPT_PER_PAGE) do |menu|
349
- entity.usable_items.each do |d|
350
- if d[:consumable]
351
- menu.choice "#{d[:label].colorize(:blue)} (#{d[:qty]})", d[:name]
352
- else
353
- menu.choice d[:label].colorize(:blue).to_s, d[:name]
354
- end
355
- end
356
- menu.choice t(:back).colorize(:blue), :back
357
- end
358
-
359
- return nil if item == :back
360
-
361
- item
362
- when :select_ground_items
363
- selected_items = prompt.multi_select("Items on the ground around #{entity.name}") do |menu|
364
- map.items_on_the_ground(entity).each do |ground_item|
365
- ground, items = ground_item
366
- items.each do |t|
367
- item_label = t("object.#{t.label}", default: t.label)
368
- menu.choice t('inventory.inventory_items', name: item_label, qty: t.qty), [ground, t]
369
- end
370
- end
371
- end
372
-
373
- return nil if selected_items.empty?
374
-
375
- item_selection = {}
376
- selected_items.each do |s_item|
377
- ground, item = s_item
378
-
379
- qty = how_many?(item)
380
- item_selection[ground] ||= []
381
- item_selection[ground] << [item, qty]
382
- end
383
-
384
- item_selection.map do |k, v|
385
- [k, v]
386
- end
387
- when :select_object
388
- target_objects = entity.usable_objects(map, battle)
389
- item = prompt.select("#{entity.name} interact with") do |menu|
390
- target_objects.each do |d|
391
- menu.choice d.name.humanize.to_s, d
392
- end
393
- menu.choice t(:manual_target), :manual_target
394
- menu.choice t(:back).colorize(:blue), :back
395
- end
396
-
397
- return nil if item == :back
398
-
399
- if item == :manual_target
400
- item = target_ui(entity, num_select: 1, validation: lambda { |selected|
401
- selected_entities = map.thing_at(*selected)
402
-
403
- return false if selected_entities.empty?
404
-
405
- selected_entities.detect do |selected_entity|
406
- target_objects.include?(selected_entity)
407
- end
408
- }).first
409
- end
410
-
411
- item
412
- when :select_items
413
- selected_items = prompt.multi_select(p[:label], per_page: TTY_PROMPT_PER_PAGE) do |menu|
414
- p[:items].each do |m|
415
- item_label = t("object.#{m.label}", default: m.label)
416
- if m.try(:equipped)
417
- menu.choice t('inventory.equiped_items', name: item_label), m
418
- else
419
- menu.choice t('inventory.inventory_items', name: item_label, qty: m.qty), m
420
- end
421
- end
422
- menu.choice t(:back).colorize(:blue), :back
423
- end
329
+ def arcane_recovery_ui(entity, spell_levels)
330
+ choice = prompt.select(t('action.arcane_recovery', name: entity.name)) do |q|
331
+ spell_levels.sort.each do |level|
332
+ q.choice t(:spell_level, level: level), level
333
+ end
334
+ q.choice t('action.waive_arcane_recovery'), :waive
335
+ end
336
+ return nil if choice == :waive
424
337
 
425
- return nil if selected_items.include?(:back)
338
+ choice
339
+ end
426
340
 
427
- selected_items = selected_items.map do |m|
428
- count = how_many?(m)
429
- [m, count]
430
- end
431
- selected_items
432
- when :interact
433
- object_action = prompt.select("#{entity.name} will") do |menu|
434
- interactions = p[:target].available_interactions(entity)
435
- class_key = p[:target].class.to_s
436
- if interactions.is_a?(Array)
437
- interactions.each do |k|
438
- menu.choice t(:"object.#{class_key}.#{k}", default: k.to_s.humanize), k
439
- end
440
- else
441
- interactions.each do |k, options|
442
- label = options[:label] || t(:"object.#{class_key}.#{k}", default: k.to_s.humanize)
443
- if options[:disabled]
444
- menu.choice label, k, disabled: options[:disabled_text]
445
- else
446
- menu.choice label, k
447
- end
448
- end
449
- end
450
- menu.choice 'Back', :back
451
- end
341
+ def spell_slots_ui(entity)
342
+ puts t(:spell_slots)
343
+ (1..9).each do |level|
344
+ next unless entity.max_spell_slots(level).positive?
452
345
 
453
- return nil if item == :back
346
+ used_slots = entity.max_spell_slots(level) - entity.spell_slots(level)
347
+ used = used_slots.times.map do
348
+ '■'
349
+ end
454
350
 
455
- object_action
456
- when :show_inventory
457
- inventory_ui(entity)
458
- else
459
- raise "unknown #{p[:type]}"
460
- end
351
+ avail = entity.spell_slots(level).times.map do
352
+ '°'
461
353
  end
462
- cont = cont.next.call(*param)
463
- break if param.nil?
354
+
355
+ puts t(:spell_level_slots, level: level, slots: (used + avail).join(' '))
464
356
  end
465
- @action = cont
466
357
  end
467
358
 
468
359
  def describe_map(map, line_of_sight: [])
@@ -482,9 +373,11 @@ class CommandlineUI < Natural20::Controller
482
373
  loop do
483
374
  describe_map(battle.map, line_of_sight: entity)
484
375
  puts @renderer.render(line_of_sight: entity)
485
- puts t(:character_status_line, hp: entity.hp, max_hp: entity.max_hp, total_actions: entity.total_actions(battle), bonus_action: entity.total_bonus_actions(battle),
376
+ puts t(:character_status_line, ac: entity.armor_class, hp: entity.hp, max_hp: entity.max_hp, total_actions: entity.total_actions(battle), bonus_action: entity.total_bonus_actions(battle),
486
377
  available_movement: entity.available_movement(battle), statuses: entity.statuses.to_a.join(','))
487
-
378
+ entity.active_effects.each do |effect|
379
+ puts t(:effect_line, effect_name: effect[:effect].label, source: effect[:source].name)
380
+ end
488
381
  action = prompt.select(t('character_action_prompt', name: entity.name, token: entity.token&.first), per_page: TTY_PROMPT_PER_PAGE,
489
382
  filter: true) do |menu|
490
383
  entity.available_actions(@session, battle).each do |a|
@@ -1,6 +1,8 @@
1
1
  module Natural20::InventoryUI
2
2
  include Natural20::Weapons
3
3
 
4
+ # Displays the character sheet of the specified entity
5
+ # @param entity [Natural20::Entity]
4
6
  def character_sheet(entity)
5
7
  puts t('character_sheet.name', name: entity.name)
6
8
  puts t('character_sheet.level', level: entity.level)
@@ -15,6 +17,8 @@ module Natural20::InventoryUI
15
17
  puts t('character_sheet.hp', current: entity.hp, max: entity.max_hp)
16
18
  puts t('character_sheet.ac', ac: entity.armor_class)
17
19
  puts t('character_sheet.speed', speed: entity.speed)
20
+ puts t('character_sheet.proficiency_bonus', bonus: entity.proficiency_bonus)
21
+ puts t('character_sheet.spell_attack', spell_attack: entity.spell_attack_modifier) if entity.has_spells?
18
22
  puts t('character_sheet.languages')
19
23
  entity.languages.each do |lang|
20
24
  puts " #{t("language.#{lang}")}"