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,56 @@
1
+ # typed: true
2
+ class HelpAction < Natural20::Action
3
+ attr_accessor :target
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
+ action: self,
12
+ param: [
13
+ {
14
+ type: :select_target,
15
+ target_types: %i[allies enemies],
16
+ range: 5,
17
+ num: 1,
18
+ },
19
+ ],
20
+ next: lambda { |target|
21
+ self.target = target
22
+ OpenStruct.new({
23
+ param: nil,
24
+ next: -> { self },
25
+ })
26
+ },
27
+ })
28
+ end
29
+
30
+ def self.build(session, source)
31
+ action = HelpAction.new(session, source, :help)
32
+ action.build_map
33
+ end
34
+
35
+ def resolve(_session, _map, opts = {})
36
+ @result = [{
37
+ source: @source,
38
+ target: @target,
39
+ type: :help,
40
+ battle: opts[:battle],
41
+ }]
42
+ self
43
+ end
44
+
45
+ def apply!(battle)
46
+ @result.each do |item|
47
+ case (item[:type])
48
+ when :help
49
+ Natural20::EventManager.received_event({ source: item[:source], target: item[:target], event: :help })
50
+ item[:source].help!(item[:battle], item[:target])
51
+ end
52
+
53
+ battle.entity_state_for(item[:source])[:action] -= 1
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,53 @@
1
+ class HideAction < Natural20::Action
2
+ attr_accessor :as_bonus_action
3
+
4
+ def self.can?(entity, battle)
5
+ battle && entity.total_actions(battle).positive?
6
+ end
7
+
8
+ def build_map
9
+ OpenStruct.new({
10
+ param: nil,
11
+ next: -> { self }
12
+ })
13
+ end
14
+
15
+ def self.build(session, source)
16
+ action = HideAction.new(session, source, :attack)
17
+ action.build_map
18
+ end
19
+
20
+ def resolve(_session, _map, opts = {})
21
+ stealth_roll = @source.stealth_check!(opts[:battle])
22
+ @result = [{
23
+ source: @source,
24
+ type: :hide,
25
+ roll: stealth_roll,
26
+ battle: opts[:battle]
27
+ }]
28
+ self
29
+ end
30
+
31
+ # @param battle [Natural20::Battle]
32
+ def apply!(battle)
33
+ @result.each do |item|
34
+ case (item[:type])
35
+ when :hide
36
+ Natural20::EventManager.received_event({ source: item[:source], roll: item[:roll], event: :hide })
37
+ item[:source].hiding!(battle, item[:roll].result)
38
+ end
39
+
40
+ if as_bonus_action
41
+ battle.entity_state_for(item[:source])[:bonus_action] -= 1
42
+ else
43
+ battle.entity_state_for(item[:source])[:action] -= 1
44
+ end
45
+ end
46
+ end
47
+ end
48
+
49
+ class HideBonusAction < HideAction
50
+ def self.can?(entity, battle)
51
+ battle && entity.any_class_feature?(%w[cunning_action nimble_escape]) && entity.total_bonus_actions(battle).positive?
52
+ end
53
+ end
@@ -0,0 +1,91 @@
1
+ # typed: true
2
+ class InteractAction < Natural20::Action
3
+ attr_accessor :target, :object_action, :other_params
4
+
5
+ # @param entity [Natural20::Entity]
6
+ # @param battle [Natural20::Battle]
7
+ def self.can?(entity, battle)
8
+ battle.nil? || !battle.ongoing? || entity.total_actions(battle).positive? || entity.free_object_interaction?(battle)
9
+ end
10
+
11
+ def self.build(session, source)
12
+ action = InteractAction.new(session, source, :attack)
13
+ action.build_map
14
+ end
15
+
16
+ def build_map
17
+ OpenStruct.new({
18
+ action: self,
19
+ param: [
20
+ {
21
+ type: :select_object
22
+ }
23
+ ],
24
+ next: lambda { |object|
25
+ self.target = object
26
+ OpenStruct.new({
27
+ param: [
28
+ {
29
+ type: :interact,
30
+ target: object
31
+ }
32
+ ],
33
+ next: lambda { |action|
34
+ self.object_action = action
35
+
36
+ custom_action = object.try(:build_map, action, self)
37
+
38
+ if custom_action.nil?
39
+ OpenStruct.new({
40
+ param: nil,
41
+ next: lambda {
42
+ self
43
+ }
44
+ })
45
+ else
46
+ custom_action
47
+ end
48
+ }
49
+
50
+ })
51
+ }
52
+ })
53
+ end
54
+
55
+ def resolve(_session, map = nil, opts = {})
56
+ battle = opts[:battle]
57
+
58
+ result = target.resolve(@source, object_action, other_params, opts)
59
+
60
+ return [] if result.nil?
61
+
62
+ result_payload = {
63
+ source: @source,
64
+ target: target,
65
+ object_action: object_action,
66
+ map: map,
67
+ battle: battle,
68
+ type: :interact
69
+ }.merge(result)
70
+ @result = [result_payload]
71
+ self
72
+ end
73
+
74
+ def apply!(battle)
75
+ @result.each do |item|
76
+ entity = item[:source]
77
+ case (item[:type])
78
+ when :interact
79
+ item[:target].use!(entity, item)
80
+ if item[:cost] == :action
81
+ battle&.consume!(entity, :action, 1)
82
+ else
83
+ battle&.consume!(entity, :free_object_interaction, 1) || battle&.consume!(entity, :action, 1)
84
+ end
85
+
86
+ Natural20::EventManager.received_event(event: :interact, source: entity, target: item[:target],
87
+ object_action: item[:object_action])
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,23 @@
1
+ # typed: true
2
+ class InventoryAction < Natural20::Action
3
+ def self.can?(_entity, _battle)
4
+ true
5
+ end
6
+
7
+ def build_map
8
+ OpenStruct.new({
9
+ action: self,
10
+ param: [
11
+ {
12
+ type: :show_inventory,
13
+ },
14
+ ],
15
+ next: lambda { |_path|
16
+ OpenStruct.new({
17
+ param: nil,
18
+ next: -> { self },
19
+ })
20
+ },
21
+ })
22
+ end
23
+ end
@@ -0,0 +1,63 @@
1
+ class LookAction < Natural20::Action
2
+ attr_accessor :ui_callback
3
+
4
+ # @param entity [Natural20::Entity]
5
+ # @param battle [Natural20::Battle]
6
+ def self.can?(entity, battle)
7
+ battle.nil? || !battle.ongoing? || battle.entity_state_for(entity)[:active_perception].zero?
8
+ end
9
+
10
+ def build_map
11
+ OpenStruct.new({
12
+ param: [
13
+ {
14
+ type: :look,
15
+ num: 1
16
+ }
17
+ ],
18
+ next: lambda { |callback|
19
+ self.ui_callback = callback
20
+ OpenStruct.new({
21
+ param: nil,
22
+ next: -> { self }
23
+ })
24
+ }
25
+ })
26
+ end
27
+
28
+ def self.build(session, source)
29
+ action = LookAction.new(session, source, :look)
30
+ action.build_map
31
+ end
32
+
33
+ def resolve(_session, _map, opts = {})
34
+ perception_check = @source.perception_check!(opts[:battle])
35
+ perception_check_2 = @source.perception_check!(opts[:battle])
36
+
37
+ perception_check_disadvantage = [perception_check, perception_check_2].min
38
+ @result = [{
39
+ source: @source,
40
+ type: :look,
41
+ die_roll: perception_check,
42
+ die_roll_disadvantage: perception_check_disadvantage,
43
+ battle: opts[:battle]
44
+ }]
45
+ self
46
+ end
47
+
48
+ def apply!(battle)
49
+ @result.each do |item|
50
+ case (item[:type])
51
+ when :look
52
+ battle.entity_state_for(item[:source])[:active_perception] = item[:die_roll].result
53
+ battle.entity_state_for(item[:source])[:active_perception_disadvantage] = item[:die_roll_disadvantage].result
54
+ Natural20::EventManager.received_event({
55
+ source: item[:source],
56
+ perception_roll: item[:die_roll],
57
+ event: :perception
58
+ })
59
+ ui_callback&.target_ui(item[:source], perception: item[:die_roll].result, look_mode: true)
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,254 @@
1
+ # typed: true
2
+ class MoveAction < Natural20::Action
3
+ include Natural20::MovementHelper
4
+ include Natural20::ActionDamage
5
+
6
+ attr_accessor :move_path, :jump_index, :as_dash, :as_bonus_action
7
+
8
+ def self.can?(entity, battle)
9
+ battle.nil? || entity.available_movement(battle).positive?
10
+ end
11
+
12
+ def build_map
13
+ OpenStruct.new({
14
+ action: self,
15
+ param: [
16
+ {
17
+ type: :movement
18
+ }
19
+ ],
20
+ next: lambda { |path_and_jump_index|
21
+ path, jump_index = path_and_jump_index
22
+ self.move_path = path
23
+ self.jump_index = jump_index
24
+ OpenStruct.new({
25
+ param: nil,
26
+ next: -> { self }
27
+ })
28
+ }
29
+ })
30
+ end
31
+
32
+ def self.build(session, source)
33
+ action = MoveAction.new(session, source, :move)
34
+ action.build_map
35
+ end
36
+
37
+ # @param map [Natural20::BattleMap]
38
+ # @option opts battle [Natural20::Battle]
39
+ def resolve(_session, map, opts = {})
40
+ raise 'no path specified' if (move_path.nil? || move_path.empty?) && opts[:move_path].nil?
41
+
42
+ @result = []
43
+ # check for melee opportunity attacks
44
+ battle = opts[:battle]
45
+
46
+ current_moves = move_path.presence || opts[:move_path]
47
+ jumps = jump_index || []
48
+
49
+ actual_moves = []
50
+ additional_effects = []
51
+
52
+ movement_budget = if as_dash
53
+ (@source.speed / 5).floor
54
+ else
55
+ (@source.available_movement(battle) / 5).floor
56
+ end
57
+
58
+ movement = compute_actual_moves(@source, current_moves, map, battle, movement_budget, manual_jump: jumps)
59
+ actual_moves = movement.movement
60
+
61
+ actual_moves.pop while actual_moves.last && !map.placeable?(@source, *actual_moves.last, battle)
62
+
63
+ actual_moves = check_opportunity_attacks(@source, actual_moves, battle)
64
+
65
+ # check if movement requires athletics checks
66
+ actual_moves = check_movement_athletics(actual_moves, movement.athletics_check_locations, battle, map)
67
+
68
+ # check if movement requires dex checks, e.g. jumping and landing on difficult terrain
69
+ actual_moves = check_movement_acrobatics(actual_moves, movement.acrobatics_check_locations, battle)
70
+
71
+ # calculate for area based triggers
72
+ cutoff = false
73
+
74
+ safe_moves = []
75
+ actual_moves.each_with_index do |move, _index|
76
+ is_flying_or_jumping = movement.jump_locations.include?(move)
77
+ trigger_results = map.area_trigger!(@source, move, is_flying_or_jumping)
78
+ if trigger_results.empty?
79
+ safe_moves << move
80
+ else
81
+ safe_moves << move
82
+ additional_effects += trigger_results
83
+ break
84
+ end
85
+ end
86
+
87
+ movement = compute_actual_moves(@source, safe_moves, map, battle, movement_budget, manual_jump: jumps)
88
+
89
+ # compute grappled entity movement
90
+ if @source.grappling?
91
+ grappled_movement = movement.movement.dup
92
+ grappled_movement.pop
93
+
94
+ @source.grappling_targets.each do |grappling_target|
95
+ start_pos = map.entity_or_object_pos(grappling_target)
96
+ grappled_entity_movement = [start_pos] + grappled_movement
97
+
98
+ additional_effects << {
99
+ source: grappling_target,
100
+ map: map,
101
+ battle: battle,
102
+ type: :move,
103
+ path: grappled_entity_movement,
104
+ move_cost: 0,
105
+ position: grappled_entity_movement.last
106
+ }
107
+
108
+ grappled_movement.pop
109
+ end
110
+ end
111
+
112
+ @result << {
113
+ source: @source,
114
+ map: map,
115
+ battle: battle,
116
+ type: :move,
117
+ path: movement.movement,
118
+ move_cost: movement_budget - movement.budget,
119
+ position: movement.movement.last
120
+ }
121
+ @result += additional_effects
122
+
123
+ self
124
+ end
125
+
126
+ # @param entity [Natural20::Entity]
127
+ # @param move_list [Array<Array<Integer,Integer>>]
128
+ # @param battle [Natural20::Battle]
129
+ def check_opportunity_attacks(entity, move_list, battle, grappled: false)
130
+ if battle && !@source.disengage?(battle)
131
+ opportunity_attacks = opportunity_attack_list(entity, move_list, battle, battle.map)
132
+ opportunity_attacks.each do |enemy_opporunity|
133
+ next unless enemy_opporunity[:source].has_reaction?(battle)
134
+ next if @source.grappling_targets.include?(enemy_opporunity[:source])
135
+
136
+ original_location = move_list[0...enemy_opporunity[:path]]
137
+ attack_location = original_location.last
138
+ battle.trigger_opportunity_attack(enemy_opporunity[:source], entity, *attack_location)
139
+
140
+ if !grappled && !entity.conscious?
141
+ move_list = original_location
142
+ break
143
+ end
144
+ end
145
+ end
146
+ move_list
147
+ end
148
+
149
+ def opportunity_attack_list(entity, current_moves, battle, map)
150
+ # get opposing forces
151
+ opponents = battle.opponents_of?(entity)
152
+ entered_melee_range = Set.new
153
+ left_melee_range = []
154
+ current_moves.each_with_index do |path, index|
155
+ opponents.each do |enemy|
156
+ entered_melee_range.add(enemy) if enemy.entered_melee?(map, entity, *path)
157
+ if !left_melee_range.include?(enemy) && entered_melee_range.include?(enemy) && !enemy.entered_melee?(map,
158
+ entity, *path)
159
+ left_melee_range << { source: enemy, path: index }
160
+ end
161
+ end
162
+ end
163
+ left_melee_range
164
+ end
165
+
166
+ def apply!(battle)
167
+ @result.each do |item|
168
+ case (item[:type])
169
+ when :state
170
+ item[:params].each do |k, v|
171
+ item[:source].send(:"#{k}=", v)
172
+ end
173
+ when :damage
174
+ damage_event(item, battle)
175
+ when :acrobatics, :athletics
176
+ if item[:success]
177
+ Natural20::EventManager.received_event(source: item[:source], event: item[:type], success: true,
178
+ roll: item[:roll])
179
+ else
180
+ Natural20::EventManager.received_event(source: item[:source], event: item[:type], success: false,
181
+ roll: item[:roll])
182
+ item[:source].prone!
183
+ end
184
+ when :drop_grapple
185
+ item[:target].escape_grapple_from!(@source)
186
+ Natural20::EventManager.received_event(event: :drop_grapple,
187
+ target: item[:target], source: @source,
188
+ source_roll: item[:source_roll],
189
+ target_roll: item[:target_roll])
190
+
191
+ when :move
192
+ item[:map].move_to!(item[:source], *item[:position], battle)
193
+ if as_dash && as_bonus_action
194
+ battle.entity_state_for(item[:source])[:bonus_action] -= 1
195
+ elsif as_dash
196
+ battle.entity_state_for(item[:source])[:action] -= 1
197
+ elsif battle
198
+ battle.entity_state_for(item[:source])[:movement] -= item[:move_cost] * battle.map.feet_per_grid
199
+ end
200
+
201
+ Natural20::EventManager.received_event({ event: :move, source: item[:source], position: item[:position], path: item[:path],
202
+ feet_per_grid: battle.map&.feet_per_grid,
203
+ as_dash: as_dash, as_bonus: as_bonus_action })
204
+ end
205
+ end
206
+ end
207
+
208
+ private
209
+
210
+ # @param actual_moves [Array]
211
+ # @param dexterity_checks [Array]
212
+ # @param battle [Natural20::Battle]
213
+ # @return [Array]
214
+ def check_movement_acrobatics(actual_moves, dexterity_checks, battle)
215
+ cutoff = actual_moves.size - 1
216
+ actual_moves.each_with_index do |m, index|
217
+ next unless dexterity_checks.include?(m)
218
+
219
+ acrobatics_roll = @source.acrobatics_check!(battle)
220
+ if acrobatics_roll.result >= 10
221
+ @result << { source: @source, type: :acrobatics, success: true, roll: acrobatics_roll, location: m }
222
+ else
223
+ @result << { source: @source, type: :acrobatics, success: false, roll: acrobatics_roll, location: m }
224
+ cutoff = index
225
+ break
226
+ end
227
+ end
228
+ actual_moves[0..cutoff]
229
+ end
230
+
231
+ # @param actual_moves [Array]
232
+ # @param athletics_checks [Array]
233
+ # @param battle [Natural20::Battle]
234
+ # @param map [Natural20::BattleMap]
235
+ # @return [Array]
236
+ def check_movement_athletics(actual_moves, athletics_checks, battle, map)
237
+ cutoff = actual_moves.size - 1
238
+ actual_moves.each_with_index do |m, index|
239
+ next unless athletics_checks.include?(m)
240
+
241
+ athletics_roll = @source.athletics_check!(battle)
242
+ if athletics_roll.result >= 10
243
+ @result << { source: @source, type: :athletics, success: true, roll: athletics_roll, location: m }
244
+ else
245
+ @result << { source: @source, type: :athletics, success: false, roll: athletics_roll, location: m }
246
+ cutoff = index - 1
247
+ cutoff -= 1 while cutoff >= 0 && !map.placeable?(@source, *actual_moves[cutoff], battle)
248
+ break
249
+ end
250
+ end
251
+
252
+ actual_moves[0..cutoff]
253
+ end
254
+ end