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,59 @@
1
+ module Natural20
2
+ module EntityStateEvaluator
3
+ # Safely evaluates a DSL to return a boolean expression
4
+ # @param conditions [String]
5
+ # @param context [Hash]
6
+ # @return [Boolean]
7
+ def eval_if(conditions, context = {})
8
+ or_groups = conditions.split('|')
9
+ !!or_groups.detect do |g|
10
+ and_groups = g.split('&')
11
+ and_groups.detect do |and_g|
12
+ cmd, test_expression = and_g.strip.split(':')
13
+ invert = test_expression[0] == '!'
14
+ test_expression = test_expression[1..test_expression.size - 1] if test_expression[0] == '!'
15
+ result = case cmd.to_sym
16
+ when :inventory
17
+ item_count(test_expression).positive?
18
+ when :equipped
19
+ equipped_items.map(&:name).include?(test_expression.to_sym)
20
+ when :object_type
21
+ context[:item_type].to_s.downcase == test_expression.to_s.downcase
22
+ when :target
23
+ (test_expression == 'object' && context[:target].object?)
24
+ when :entity
25
+ (test_expression == 'pc' && pc?) ||
26
+ (test_expression == 'npc' && npc?)
27
+ when :state
28
+ case test_expression
29
+ when 'unconscious'
30
+ unconscious?
31
+ when 'stable'
32
+ stable?
33
+ when 'dead'
34
+ dead?
35
+ when 'conscious'
36
+ conscious?
37
+ end
38
+ else
39
+ raise "Invalid expression #{cmd} #{test_expression}"
40
+ end
41
+
42
+ invert ? result : !result
43
+ end.nil?
44
+ end
45
+ end
46
+
47
+ def apply_effect(expression, context = {})
48
+ action, value = expression.split(':')
49
+ case action
50
+ when 'status'
51
+ {
52
+ source: self,
53
+ type: value.to_sym,
54
+ battle: context[:battle]
55
+ }
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,51 @@
1
+ # typed: true
2
+ class SecondWindAction < Natural20::Action
3
+ def self.can?(entity, battle)
4
+ (battle.nil? || entity.total_bonus_actions(battle).positive?) && entity.second_wind_count.positive?
5
+ end
6
+
7
+ def label
8
+ 'Second Wind'
9
+ end
10
+
11
+ def build_map
12
+ OpenStruct.new({
13
+ action: self,
14
+ param: nil,
15
+ next: -> { self }
16
+ })
17
+ end
18
+
19
+ def self.build(session, source)
20
+ action = SecondWindAction.new(session, source, :second_wind)
21
+ action.build_map
22
+ end
23
+
24
+ def resolve(_session, _map, opts = {})
25
+ second_wind_roll = Natural20::DieRoll.roll(@source.second_wind_die, description: t('dice_roll.second_wind'),
26
+ entity: @source, battle: opts[:battle])
27
+ @result = [{
28
+ source: @source,
29
+ roll: second_wind_roll,
30
+ type: :second_wind,
31
+ battle: opts[:battle]
32
+ }]
33
+ self
34
+ end
35
+
36
+ def apply!(battle)
37
+ @result.each do |item|
38
+ case (item[:type])
39
+ when :second_wind
40
+ Natural20::EventManager.received_event(action: self.class, source: item[:source], roll: item[:roll],
41
+ event: :second_wind)
42
+ item[:source].second_wind!(item[:roll].result)
43
+ battle.entity_state_for(item[:source])[:bonus_action] -= 1
44
+ end
45
+ end
46
+ end
47
+
48
+ def self.describe(event)
49
+ "#{event[:source].name.colorize(:green)} uses " + 'Second Wind'.colorize(:blue) + " with #{event[:roll]} healing"
50
+ end
51
+ end
@@ -0,0 +1,35 @@
1
+ # typed: false
2
+ require "natural_20/concerns/fighter_actions/second_wind_action"
3
+
4
+ module Natural20::FighterClass
5
+ attr_accessor :fighter_level, :second_wind_count
6
+
7
+ def initialize_fighter
8
+ @second_wind_count = 1
9
+ end
10
+
11
+ def second_wind_die
12
+ "1d10+#{@fighter_level}"
13
+ end
14
+
15
+ def second_wind!(amt)
16
+ @second_wind_count -= 1
17
+ heal!(amt)
18
+ end
19
+
20
+ def special_actions_for_fighter(session, battle)
21
+ %i[second_wind].map do |type|
22
+ next unless "#{type.to_s.camelize}Action".constantize.can?(self, battle)
23
+
24
+ case type
25
+ when :second_wind
26
+ SecondWindAction.new(session, self, :second_wind)
27
+ end
28
+ end.compact
29
+ end
30
+
31
+ # hooks for the fighter class during a short rest
32
+ def short_rest_for_fighter
33
+ @second_wind_count = 1
34
+ end
35
+ end
@@ -0,0 +1,27 @@
1
+ # typed: false
2
+ module Natural20::HealthFlavor
3
+ def describe_health
4
+ return '' if hp.zero? || hp.negative?
5
+
6
+ percentage = (hp.to_f / max_hp) * 100
7
+
8
+ token = if dead?
9
+ 'dead'
10
+ elsif unconscious?
11
+ 'unconscious'
12
+ elsif percentage > 90
13
+ 'max'
14
+ elsif percentage > 75
15
+ 'over_75'
16
+ elsif percentage > 50
17
+ 'over_50'
18
+ elsif percentage > 25
19
+ 'over_25'
20
+ elsif percentage > 10
21
+ 'over_10'
22
+ else
23
+ 'almost_dead'
24
+ end
25
+ t("entity.health_flavor.#{token}")
26
+ end
27
+ end
@@ -0,0 +1,94 @@
1
+ module Natural20::Lootable
2
+ include Natural20::Container
3
+
4
+ # Builds a custom UI map
5
+ # @param action [Symbol] The item specific action
6
+ # @param action_object [InteractAction]
7
+ # @return [OpenStruct]
8
+ def build_map(action, action_object)
9
+ case action
10
+ when :give
11
+ OpenStruct.new({
12
+ action: action_object,
13
+ param: [
14
+ {
15
+ type: :select_items,
16
+ label: action_object.source.items_label,
17
+ items: action_object.source.inventory
18
+ }
19
+ ],
20
+ next: lambda { |items|
21
+ action_object.other_params = items
22
+ OpenStruct.new({
23
+ param: nil,
24
+ next: lambda {
25
+ action_object
26
+ }
27
+ })
28
+ }
29
+ })
30
+ when :loot
31
+ OpenStruct.new({
32
+ action: action_object,
33
+ param: [
34
+ {
35
+ type: :select_items,
36
+ label: items_label,
37
+ items: inventory + equipped_items
38
+ }
39
+ ],
40
+ next: lambda { |items|
41
+ action_object.other_params = items
42
+ OpenStruct.new({
43
+ param: nil,
44
+ next: lambda {
45
+ action_object
46
+ }
47
+ })
48
+ }
49
+ })
50
+ end
51
+ end
52
+
53
+ # Lists default available interactions for an entity
54
+ # @param entity [Natural20::Entity]
55
+ # @param battle [Natural20::Battle]
56
+ # @return [Array] List of availabel actions
57
+ def available_interactions(entity, battle = nil)
58
+ other_interactions = super entity, battle
59
+ other_interactions << :give if !npc? || object?
60
+ # other_interactions << :pickpocket if !unconscious && npc?
61
+ other_interactions << :loot if (dead? || unconscious? || opened?) && inventory_count.positive?
62
+
63
+ other_interactions
64
+ end
65
+
66
+ def interactable?
67
+ true
68
+ end
69
+
70
+ # @param entity [Natural20::Entity]
71
+ # @param action [InteractAction]
72
+ # @param other_params [Hash]
73
+ def resolve(entity, action, other_params, opts = {})
74
+ return if action.nil?
75
+
76
+ case action
77
+ when :give, :loot
78
+ { action: action, items: other_params, source: entity, target: self, battle: opts[:battle] }
79
+ end
80
+ end
81
+
82
+ # @param entity [Natural20::Entity]
83
+ # @option result action [Symbol]
84
+ # @option result items [Array]
85
+ # @option result source [Natural20::Entity]
86
+ def use!(_entity, result)
87
+ case (result[:action])
88
+ when :give
89
+ store(result[:battle], result[:source], result[:target], result[:items])
90
+ when :loot
91
+ retrieve(result[:battle], result[:source], result[:target], result[:items])
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,195 @@
1
+ # typed: true
2
+ module Natural20::MovementHelper
3
+ class Movement
4
+ def initialize(movement, original_budget, acrobatics_check_locations, athletics_check_locations, jump_locations, jump_start_locations, land_locations, jump_budget, budget, impediment)
5
+ @jump_start_locations = jump_start_locations
6
+ @athletics_check_locations = athletics_check_locations
7
+ @jump_locations = jump_locations
8
+ @land_locations = land_locations
9
+ @jump_budget = jump_budget
10
+ @movement = movement
11
+ @original_budget = original_budget
12
+ @acrobatics_check_locations = acrobatics_check_locations
13
+ @impediment = impediment
14
+ @budget = budget
15
+ end
16
+
17
+ # @return [Natural20::Movement]
18
+ def self.empty
19
+ Movement.new([], 0, [], [], [], [], [], 0, 0, :nil)
20
+ end
21
+
22
+ # @return [Integer]
23
+ attr_reader :budget
24
+
25
+ # @return [Symbol]
26
+ attr_reader :impediment
27
+
28
+ # @return [Array]
29
+ attr_reader :movement
30
+
31
+ # @return [Integer]
32
+ attr_reader :jump_budget
33
+
34
+ # @return [Array]
35
+ attr_reader :jump_start_locations
36
+
37
+ # @return [Array]
38
+ attr_reader :jump_locations
39
+
40
+ # @return [Array]
41
+ attr_reader :land_locations
42
+
43
+ # @return [Array]
44
+ attr_reader :acrobatics_check_locations
45
+
46
+ # @return [Array]
47
+ attr_reader :athletics_check_locations
48
+
49
+ # @return [Integer]
50
+ def cost
51
+ (@original_budget - @budget)
52
+ end
53
+ end
54
+
55
+ # Checks if move path is valid
56
+ # @param entity [Natural20::Entity]
57
+ # @param path [Array]
58
+ # @param battle [Natural20::Battle]
59
+ # @param map [Natural20::BattleMap]
60
+ # @param test_placement [Boolean]
61
+ # @param manual_jump [Array]
62
+ # @return [Boolean]
63
+ def valid_move_path?(entity, path, battle, map, test_placement: true, manual_jump: [])
64
+ path == compute_actual_moves(entity, path, map, battle, entity.available_movement(battle) / map.feet_per_grid,
65
+ test_placement: test_placement, manual_jump: manual_jump).movement
66
+ end
67
+
68
+ # Determine if entity needs to squeeze to get through terrain
69
+ # @param entity [Natural20::Entity]
70
+ # @param pos_x [Integer]
71
+ # @param pos_y [Integer]
72
+ # @param map [Natural20::BattleMap]
73
+ # @param battle [Natural20::Battle]
74
+ # @return [Boolean]
75
+ def requires_squeeze?(entity, pos_x, pos_y, map, battle = nil)
76
+ !map.passable?(entity, pos_x, pos_y, battle, false) && map.passable?(entity, pos_x, pos_y, battle, true)
77
+ end
78
+
79
+ # @param entity [Natural20::Entity]
80
+ # @param current_moves [Array]
81
+ # @param map [Natural20::BattleMap]
82
+ # @param battle [Natural20::Battle]
83
+ # @param movement_budget [Integer] movement budget in number of squares (feet/5 by default)
84
+ # @param test_placement [Boolean] If true tests if last move is placeable on the map
85
+ # @param manual_jump [Array] Indices of moves that are supposed to be jumps
86
+ # @return [Movement]
87
+ def compute_actual_moves(entity, current_moves, map, battle, movement_budget, fixed_movement: false, test_placement: true, manual_jump: [])
88
+ actual_moves = []
89
+ provisional_moves = []
90
+ jump_budget = (entity.standing_jump_distance / map.feet_per_grid).floor
91
+ running_distance = 1
92
+ jump_distance = 0
93
+ jumped = false
94
+ acrobatics_check_locations = []
95
+ athletics_check_locations = []
96
+ jump_start_locations = []
97
+ land_locations = []
98
+ jump_locations = []
99
+ impediment = nil
100
+ original_budget = movement_budget
101
+
102
+ current_moves.each_with_index do |m, index|
103
+ raise "invalid move coordinate" unless m.size == 2 # assert move correctness
104
+
105
+ unless index.positive?
106
+ actual_moves << m
107
+ next
108
+ end
109
+
110
+ unless map.passable?(entity, *m, battle)
111
+ impediment = :path_blocked
112
+ break
113
+ end
114
+
115
+ if fixed_movement
116
+ movement_budget -= 1
117
+ else
118
+ movement_budget -= if !manual_jump.include?(index) && map.difficult_terrain?(entity, *m, battle)
119
+ 2
120
+ else
121
+ 1
122
+ end
123
+ movement_budget -= 1 if requires_squeeze?(entity, *m, map, battle)
124
+ movement_budget -= 1 if entity.prone?
125
+ movement_budget -= 1 if entity.grappling?
126
+ end
127
+
128
+ if movement_budget.negative?
129
+ impediment = :movement_budget
130
+ break
131
+ end
132
+
133
+ if !fixed_movement && (map.jump_required?(entity, *m) || manual_jump.include?(index))
134
+ if entity.prone? # can't jump if prone
135
+ impediment = :prone_need_to_jump
136
+ break
137
+ end
138
+
139
+ jump_start_locations << m unless jumped
140
+ jump_locations << m
141
+ jump_budget -= 1
142
+ jump_distance += 1
143
+ if !fixed_movement && jump_budget.negative?
144
+ impediment = :jump_distance_not_enough
145
+ break
146
+ end
147
+
148
+ running_distance = 0
149
+ jumped = true
150
+ provisional_moves << m
151
+
152
+ entity_at_square = map.entity_at(*m)
153
+ athletics_check_locations << m if entity_at_square&.conscious? && !entity_at_square&.prone?
154
+ else
155
+ actual_moves += provisional_moves
156
+ provisional_moves.clear
157
+
158
+ land_locations << m if jumped
159
+ acrobatics_check_locations << m if jumped && map.difficult_terrain?(entity, *m,
160
+ battle)
161
+ running_distance += 1
162
+
163
+ # if jump not required reset jump budgets
164
+ jump_budget = if running_distance > 1
165
+ (entity.long_jump_distance / map.feet_per_grid).floor
166
+ else
167
+ (entity.standing_jump_distance / map.feet_per_grid).floor
168
+ end
169
+ jumped = false
170
+ jump_distance = 0
171
+ actual_moves << m
172
+ end
173
+ end
174
+
175
+ # handle case where end is a jump, in that case we land if this is possible
176
+ unless provisional_moves.empty?
177
+ actual_moves += provisional_moves
178
+ m = actual_moves.last
179
+ land_locations << m if jumped
180
+ acrobatics_check_locations << m if jumped && map.difficult_terrain?(entity, *m,
181
+ battle)
182
+ jump_locations.delete(actual_moves.last)
183
+ end
184
+
185
+ while test_placement && !map.placeable?(entity, *actual_moves.last, battle)
186
+ impediment = :not_placeable
187
+ jump_locations.delete(actual_moves.last)
188
+ actual_moves.pop
189
+ end
190
+
191
+ Movement.new(actual_moves, original_budget, acrobatics_check_locations, athletics_check_locations, jump_locations, jump_start_locations, land_locations, jump_budget,
192
+ movement_budget, impediment)
193
+ end
194
+
195
+ end