natural_20 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
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