natural_20 0.1.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 (114) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +14 -0
  3. data/.rspec +3 -0
  4. data/.travis.yml +6 -0
  5. data/CODE_OF_CONDUCT.md +74 -0
  6. data/Gemfile +7 -0
  7. data/LICENSE.txt +21 -0
  8. data/README.md +99 -0
  9. data/Rakefile +6 -0
  10. data/bin/compute_lights +19 -0
  11. data/bin/console +19 -0
  12. data/bin/nat20 +135 -0
  13. data/bin/nat20.cmd +3 -0
  14. data/bin/nat20author +104 -0
  15. data/bin/setup +8 -0
  16. data/char_classes/fighter.yml +45 -0
  17. data/char_classes/rogue.yml +54 -0
  18. data/characters/halfling_rogue.yml +46 -0
  19. data/characters/high_elf_fighter.yml +49 -0
  20. data/fixtures/battle_sim.yml +58 -0
  21. data/fixtures/battle_sim_2.yml +30 -0
  22. data/fixtures/battle_sim_3.yml +26 -0
  23. data/fixtures/battle_sim_4.yml +26 -0
  24. data/fixtures/battle_sim_objects.yml +101 -0
  25. data/fixtures/corridors.yml +24 -0
  26. data/fixtures/elf_rogue.yml +39 -0
  27. data/fixtures/halfling_rogue.yml +41 -0
  28. data/fixtures/high_elf_fighter.yml +49 -0
  29. data/fixtures/human_fighter.yml +48 -0
  30. data/fixtures/path_finding_test.yml +11 -0
  31. data/fixtures/path_finding_test_2.yml +15 -0
  32. data/fixtures/path_finding_test_3.yml +26 -0
  33. data/fixtures/thin_walls.yml +53 -0
  34. data/fixtures/traps.yml +25 -0
  35. data/game.yml +20 -0
  36. data/items/equipment.yml +101 -0
  37. data/items/objects.yml +73 -0
  38. data/items/weapons.yml +297 -0
  39. data/lib/natural_20.rb +68 -0
  40. data/lib/natural_20/actions/action.rb +40 -0
  41. data/lib/natural_20/actions/attack_action.rb +372 -0
  42. data/lib/natural_20/actions/concerns/action_damage.rb +14 -0
  43. data/lib/natural_20/actions/dash_action.rb +46 -0
  44. data/lib/natural_20/actions/disengage_action.rb +53 -0
  45. data/lib/natural_20/actions/dodge_action.rb +45 -0
  46. data/lib/natural_20/actions/escape_grapple_action.rb +97 -0
  47. data/lib/natural_20/actions/first_aid_action.rb +109 -0
  48. data/lib/natural_20/actions/grapple_action.rb +185 -0
  49. data/lib/natural_20/actions/ground_interact_action.rb +74 -0
  50. data/lib/natural_20/actions/help_action.rb +56 -0
  51. data/lib/natural_20/actions/hide_action.rb +53 -0
  52. data/lib/natural_20/actions/interact_action.rb +91 -0
  53. data/lib/natural_20/actions/inventory_action.rb +23 -0
  54. data/lib/natural_20/actions/look_action.rb +63 -0
  55. data/lib/natural_20/actions/move_action.rb +254 -0
  56. data/lib/natural_20/actions/multiattack_action.rb +41 -0
  57. data/lib/natural_20/actions/prone_action.rb +38 -0
  58. data/lib/natural_20/actions/short_rest_action.rb +53 -0
  59. data/lib/natural_20/actions/shove_action.rb +142 -0
  60. data/lib/natural_20/actions/stand_action.rb +47 -0
  61. data/lib/natural_20/actions/use_item_action.rb +57 -0
  62. data/lib/natural_20/ai_controller/path_compute.rb +140 -0
  63. data/lib/natural_20/ai_controller/standard.rb +288 -0
  64. data/lib/natural_20/battle.rb +544 -0
  65. data/lib/natural_20/battle_map.rb +843 -0
  66. data/lib/natural_20/cli/builder/fighter_builder.rb +104 -0
  67. data/lib/natural_20/cli/builder/rogue_builder.rb +62 -0
  68. data/lib/natural_20/cli/character_builder.rb +210 -0
  69. data/lib/natural_20/cli/commandline_ui.rb +612 -0
  70. data/lib/natural_20/cli/inventory_ui.rb +136 -0
  71. data/lib/natural_20/cli/map_renderer.rb +165 -0
  72. data/lib/natural_20/concerns/container.rb +32 -0
  73. data/lib/natural_20/concerns/entity.rb +1213 -0
  74. data/lib/natural_20/concerns/evaluator/entity_state_evaluator.rb +59 -0
  75. data/lib/natural_20/concerns/fighter_actions/second_wind_action.rb +51 -0
  76. data/lib/natural_20/concerns/fighter_class.rb +35 -0
  77. data/lib/natural_20/concerns/health_flavor.rb +27 -0
  78. data/lib/natural_20/concerns/lootable.rb +94 -0
  79. data/lib/natural_20/concerns/movement_helper.rb +195 -0
  80. data/lib/natural_20/concerns/multiattack.rb +54 -0
  81. data/lib/natural_20/concerns/navigation.rb +87 -0
  82. data/lib/natural_20/concerns/notable.rb +37 -0
  83. data/lib/natural_20/concerns/rogue_class.rb +26 -0
  84. data/lib/natural_20/controller.rb +11 -0
  85. data/lib/natural_20/die_roll.rb +331 -0
  86. data/lib/natural_20/event_manager.rb +288 -0
  87. data/lib/natural_20/item_library/base_item.rb +27 -0
  88. data/lib/natural_20/item_library/chest.rb +230 -0
  89. data/lib/natural_20/item_library/door_object.rb +189 -0
  90. data/lib/natural_20/item_library/ground.rb +124 -0
  91. data/lib/natural_20/item_library/healing_potion.rb +51 -0
  92. data/lib/natural_20/item_library/object.rb +153 -0
  93. data/lib/natural_20/item_library/pit_trap.rb +69 -0
  94. data/lib/natural_20/item_library/stone_wall.rb +18 -0
  95. data/lib/natural_20/npc.rb +173 -0
  96. data/lib/natural_20/player_character.rb +414 -0
  97. data/lib/natural_20/session.rb +168 -0
  98. data/lib/natural_20/utils/cover.rb +35 -0
  99. data/lib/natural_20/utils/ray_tracer.rb +90 -0
  100. data/lib/natural_20/utils/static_light_builder.rb +72 -0
  101. data/lib/natural_20/utils/weapons.rb +78 -0
  102. data/lib/natural_20/version.rb +4 -0
  103. data/locales/en.yml +304 -0
  104. data/maps/game_map.yml +168 -0
  105. data/natural_20.gemspec +46 -0
  106. data/npcs/goblin.yml +64 -0
  107. data/npcs/human_guard.yml +48 -0
  108. data/npcs/ogre.yml +61 -0
  109. data/npcs/owlbear.yml +55 -0
  110. data/npcs/wolf.yml +46 -0
  111. data/races/elf.yml +44 -0
  112. data/races/halfling.yml +22 -0
  113. data/races/human.yml +13 -0
  114. metadata +373 -0
@@ -0,0 +1,41 @@
1
+ # typed: true
2
+ class MultiattackAction < Natural20::Action
3
+ attr_accessor :as_bonus_action
4
+
5
+ def self.can?(entity, battle)
6
+ battle && entity.total_actions(battle).positive?
7
+ end
8
+
9
+ def build_map
10
+ OpenStruct.new({
11
+ param: nil,
12
+ next: -> { self },
13
+ })
14
+ end
15
+
16
+ def self.build(session, source)
17
+ action = MultiattackAction.new(session, source, :multiattack)
18
+ action.build_map
19
+ end
20
+
21
+ def resolve(_session, _map, opts = {})
22
+ @result = [{
23
+ source: @source,
24
+ type: :multiattack,
25
+ battle: opts[:battle],
26
+ }]
27
+ self
28
+ end
29
+
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
+
38
+ battle.consume!(:action, 1)
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,38 @@
1
+ # typed: true
2
+ class ProneAction < Natural20::Action
3
+ attr_accessor :as_bonus_action
4
+
5
+ def self.can?(entity, battle)
6
+ battle && !entity.prone?
7
+ end
8
+
9
+ def build_map
10
+ OpenStruct.new({
11
+ param: nil,
12
+ next: -> { self }
13
+ })
14
+ end
15
+
16
+ def self.build(session, source)
17
+ action = ProneAction.new(session, source, :attack)
18
+ action.build_map
19
+ end
20
+
21
+ def resolve(_session, _map, opts = {})
22
+ @result = [{
23
+ source: @source,
24
+ type: :prone,
25
+ battle: opts[:battle]
26
+ }]
27
+ self
28
+ end
29
+
30
+ def apply!(_battle)
31
+ @result.each do |item|
32
+ case (item[:type])
33
+ when :prone
34
+ item[:source].prone!
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,53 @@
1
+ class ShortRestAction < Natural20::Action
2
+ attr_accessor :as_bonus_action
3
+
4
+ # @param entity [Natural20::Entity]
5
+ # @param battle [Natural20::Battle]
6
+ def self.can?(entity, battle)
7
+ battle && !battle.combat? && (entity.conscious? || entity.stable?)
8
+ end
9
+
10
+ def build_map
11
+ OpenStruct.new({
12
+ param: nil,
13
+ next: -> { self }
14
+ })
15
+ end
16
+
17
+ def self.build(session, source)
18
+ action = ShortRestAction.new(session, source, :short_rest)
19
+ action.build_map
20
+ end
21
+
22
+ # @option opts battle [Natural20::Battle]
23
+ def resolve(_session, _map, opts = {})
24
+ battle = opts[:battle]
25
+
26
+ # determine who in the party is eligible for short rest
27
+ entities = battle.current_party.select do |entity|
28
+ entity.conscious? || entity.stable?
29
+ end
30
+
31
+ @result = [{
32
+ source: @source,
33
+ targets: entities,
34
+ type: :short_rest,
35
+ battle: battle
36
+ }]
37
+
38
+ self
39
+ end
40
+
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
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,142 @@
1
+ class ShoveAction < Natural20::Action
2
+ include Natural20::ActionDamage
3
+ attr_accessor :target, :knock_prone
4
+
5
+ def self.can?(entity, battle, _options = {})
6
+ (battle.nil? || entity.total_actions(battle).positive?)
7
+ end
8
+
9
+ def validate
10
+ @errors << 'target is a required option for :attack' if target.nil?
11
+ @errors << t('validation.shove.invalid_target_size') if (target.size_identifier - @source.size_identifier) > 1
12
+ end
13
+
14
+ def to_s
15
+ @action_type.to_s.humanize
16
+ end
17
+
18
+ def build_map
19
+ OpenStruct.new({
20
+ action: self,
21
+ param: [
22
+ {
23
+ type: :select_target,
24
+ range: 5,
25
+ target_types: %i[enemies],
26
+ num: 1
27
+ }
28
+ ],
29
+ next: lambda { |target|
30
+ self.target = target
31
+ OpenStruct.new({
32
+ param: nil,
33
+ next: -> { self }
34
+ })
35
+ }
36
+ })
37
+ end
38
+
39
+ def self.build(session, source)
40
+ action = ShoveAction.new(session, source, :shove)
41
+ action.build_map
42
+ end
43
+
44
+ # Build the attack roll information
45
+ # @param session [Natural20::Session]
46
+ # @param map [Natural20::BattleMap]
47
+ # @option opts battle [Natural20::Battle]
48
+ # @option opts target [Natural20::Entity]
49
+ def resolve(_session, map, opts = {})
50
+ target = opts[:target] || @target
51
+ battle = opts[:battle]
52
+ raise 'target is a required option for :attack' if target.nil?
53
+
54
+ return if (target.size_identifier - @source.size_identifier) > 1
55
+
56
+ strength_roll = @source.athletics_check!(battle)
57
+ athletics_stats = (@target.athletics_proficient? ? @target.proficiency_bonus : 0) + @target.str_mod
58
+ acrobatics_stats = (@target.acrobatics_proficient? ? @target.proficiency_bonus : 0) + @target.dex_mod
59
+
60
+ shove_success = false
61
+ if @target.incapacitated?
62
+ shove_success = true
63
+ else
64
+ contested_roll = if athletics_stats > acrobatics_stats
65
+ @target.athletics_check!(battle,
66
+ description: t('die_roll.contest'))
67
+ else
68
+ @target.acrobatics_check!(
69
+ opts[:battle], description: t('die_roll.contest')
70
+ )
71
+ end
72
+ shove_success = strength_roll.result >= contested_roll.result
73
+ end
74
+
75
+ shove_loc = nil
76
+ additional_effects = []
77
+ unless knock_prone
78
+ shove_loc = @target.push_from(map, *map.entity_or_object_pos(@source))
79
+ if shove_loc
80
+ trigger_results = map.area_trigger!(@target, shove_loc, false)
81
+ additional_effects += trigger_results
82
+ end
83
+ end
84
+ @result = if shove_success
85
+ [{
86
+ source: @source,
87
+ target: target,
88
+ type: :shove,
89
+ success: true,
90
+ battle: battle,
91
+ shove_loc: shove_loc,
92
+ knock_prone: knock_prone,
93
+ source_roll: strength_roll,
94
+ target_roll: contested_roll
95
+ }] + additional_effects
96
+ else
97
+ [{
98
+ source: @source,
99
+ target: target,
100
+ type: :shove,
101
+ success: false,
102
+ battle: battle,
103
+ knock_prone: knock_prone,
104
+ source_roll: strength_roll,
105
+ target_roll: contested_roll
106
+ }]
107
+ end
108
+ end
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])
133
+ end
134
+
135
+ battle.entity_state_for(item[:source])[:action] -= 1
136
+ end
137
+ end
138
+ end
139
+ end
140
+
141
+ class PushAction < ShoveAction
142
+ end
@@ -0,0 +1,47 @@
1
+ # typed: true
2
+ class StandAction < Natural20::Action
3
+ attr_accessor :as_bonus_action
4
+
5
+ # @param entity [Natural20::Entity]
6
+ # @param battle [Natural20::Battle]
7
+ def self.can?(entity, battle)
8
+ battle && entity.prone? && entity.speed.positive? && entity.available_movement(battle) >= required_movement(entity)
9
+ end
10
+
11
+ def build_map
12
+ OpenStruct.new({
13
+ param: nil,
14
+ next: -> { self }
15
+ })
16
+ end
17
+
18
+ def self.build(session, source)
19
+ action = StandAction.new(session, source, :attack)
20
+ action.build_map
21
+ end
22
+
23
+ def resolve(_session, _map, opts = {})
24
+ @result = [{
25
+ source: @source,
26
+ type: :stand,
27
+ battle: opts[:battle]
28
+ }]
29
+ self
30
+ end
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
39
+ end
40
+ end
41
+
42
+ # required movement available to stand
43
+ # @param entity [Natural20::Entity]
44
+ def self.required_movement(entity)
45
+ (entity.speed / 2).floor
46
+ end
47
+ end
@@ -0,0 +1,57 @@
1
+ # typed: true
2
+ class UseItemAction < Natural20::Action
3
+ attr_accessor :target, :target_item
4
+
5
+ def self.can?(entity, battle)
6
+ battle.nil? || entity.total_actions(battle).positive?
7
+ end
8
+
9
+ def self.build(session, source)
10
+ action = UseItemAction.new(session, source, :attack)
11
+ action.build_map
12
+ end
13
+
14
+ def build_map
15
+ OpenStruct.new({
16
+ action: self,
17
+ param: [
18
+ {
19
+ type: :select_item
20
+ }
21
+ ],
22
+ next: lambda { |item|
23
+ item_details = session.load_equipment(item)
24
+ raise "item #{item_details[:name]} not usable!" unless item_details[:usable]
25
+
26
+ @target_item = item_details[:item_class].constantize.new(item, item_details)
27
+ @target_item.build_map(self)
28
+ }
29
+ })
30
+ end
31
+
32
+ def resolve(_session, map = nil, opts = {})
33
+ battle = opts[:battle]
34
+ result_payload = {
35
+ source: @source,
36
+ target: target,
37
+ map: map,
38
+ battle: battle,
39
+ type: :use_item,
40
+ item: target_item
41
+ }.merge(target_item.resolve(@source, battle))
42
+ @result = [result_payload]
43
+ self
44
+ end
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
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,140 @@
1
+ # typed: false
2
+ require 'pqueue'
3
+ module AiController
4
+ MAX_DISTANCE = 4_000_000
5
+ # Path finding algorithm
6
+ class PathCompute
7
+ # Creates a path finder
8
+ # @param battle [Natural20::Battle]
9
+ # @param map [Natural20::BattleMap]
10
+ # @param entity [Natural20::Entity]
11
+ def initialize(battle, map, entity)
12
+ @entity = entity
13
+ @map = map
14
+ @battle = battle
15
+ @max_x, @max_y = @map.size
16
+ end
17
+
18
+ def build_structures(source_x, source_y)
19
+ @pq = PQueue.new([]) { |a, b| a[1] < b[1] }
20
+ @visited_nodes = Set.new
21
+
22
+ @distances = @max_x.times.map do
23
+ @max_y.times.map do
24
+ MAX_DISTANCE
25
+ end
26
+ end
27
+
28
+ @current_node = [source_x, source_y]
29
+ @distances[source_x][source_y] = 0
30
+ @visited_nodes.add(@current_node)
31
+ end
32
+
33
+ def backtrace(source_x, source_y, destination_x, destination_y, show_cost: false)
34
+ path = []
35
+ current_node = [destination_x, destination_y]
36
+ return nil if @distances[destination_x][destination_y] == MAX_DISTANCE # no route!
37
+
38
+ path << current_node
39
+ cost = @distances[destination_x][destination_y]
40
+ visited_nodes = Set.new
41
+ visited_nodes.add(current_node)
42
+ Kernel.loop do
43
+ adjacent_squares = get_adjacent_from(*current_node)
44
+ adjacent_squares += get_adjacent_from(*current_node, squeeze: true)
45
+
46
+ min_node = nil
47
+ min_distance = nil
48
+
49
+ adjacent_squares.reject { |n| visited_nodes.include?(n) }.each do |node|
50
+ line_distance = Math.sqrt((destination_x - node[0])**2 + (destination_y - node[1])**2)
51
+ current_distance = @distances[node[0]][node[1]].to_f + line_distance / MAX_DISTANCE.to_f
52
+ if min_node.nil? || current_distance < min_distance
53
+ min_distance = current_distance
54
+ min_node = node
55
+ end
56
+ end
57
+
58
+ return nil if min_node.nil?
59
+
60
+ path << min_node
61
+ current_node = min_node
62
+ visited_nodes.add(current_node)
63
+ break if current_node == [source_x, source_y]
64
+ end
65
+
66
+ show_cost ? [path.reverse, cost] : path.reverse
67
+ end
68
+
69
+ def path(destination = nil)
70
+ Kernel.loop do
71
+ distance = @distances[@current_node[0]][@current_node[1]]
72
+
73
+ adjacent_squares = get_adjacent_from(*@current_node)
74
+ visit_squares(@pq, adjacent_squares, @visited_nodes, @distances, distance)
75
+
76
+ # with squeezing into terrain
77
+ squeeze_adjacent_squares = get_adjacent_from(*@current_node, squeeze: true)
78
+
79
+ squeeze_adjacent_squares -= adjacent_squares
80
+
81
+ unless squeeze_adjacent_squares.empty?
82
+ visit_squares(@pq, squeeze_adjacent_squares, @visited_nodes, @distances, distance,
83
+ 2)
84
+ end
85
+
86
+ break if destination && @current_node == destination
87
+
88
+ @visited_nodes.add(@current_node)
89
+
90
+ @current_node, node_d = @pq.pop
91
+ break if @current_node.nil?
92
+
93
+ return nil if node_d == MAX_DISTANCE
94
+ end
95
+ end
96
+
97
+ # compute path using Djikstras shortest path
98
+ def compute_path(source_x, source_y, destination_x, destination_y)
99
+ build_structures(source_x, source_y)
100
+ path([destination_x, destination_y])
101
+ backtrace(source_x, source_y, destination_x, destination_y)
102
+ end
103
+
104
+ def incremental_path(source_x, source_y, destination_x, destination_y)
105
+ rpath = backtrace(source_x, source_y, destination_x, destination_y, show_cost: true)
106
+ return rpath if rpath && rpath.size > 1
107
+
108
+ nil
109
+ end
110
+
111
+ # @param pq [PQueue]
112
+ # @param adjacent_squares [Array<Array<Integer,Integer>>]
113
+ def visit_squares(pq, adjacent_squares, visited_nodes, distances, distance, override_move_cost = nil)
114
+ adjacent_squares.reject { |n| visited_nodes.include?(n) }.each do |node|
115
+ move_cost = override_move_cost || @map.difficult_terrain?(@entity, node[0], node[1], @battle) ? 2 : 1
116
+ current_distance = distance + move_cost
117
+ distances[node[0]][node[1]] = current_distance if distances[node[0]][node[1]] > current_distance
118
+ pq << [node, distances[node[0]][node[1]]]
119
+ end
120
+ end
121
+
122
+ def get_adjacent_from(pos_x, pos_y, squeeze: false)
123
+ valid_paths = Set.new
124
+ [-1, 0, 1].each do |x_op|
125
+ [-1, 0, 1].each do |y_op|
126
+ cur_x = pos_x + x_op
127
+ cur_y = pos_y + y_op
128
+
129
+ next if cur_x < 0 || cur_y < 0 || cur_x >= @max_x || cur_y >= @max_y
130
+ next if x_op.zero? && y_op.zero?
131
+ next unless @map.passable?(@entity, cur_x, cur_y, @battle, squeeze)
132
+
133
+ valid_paths.add([cur_x, cur_y])
134
+ end
135
+ end
136
+
137
+ valid_paths
138
+ end
139
+ end
140
+ end