sc2ai 0.0.0.pre → 0.0.2

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 (94) hide show
  1. checksums.yaml +4 -4
  2. data/data/data.json +1 -0
  3. data/data/data_readable.json +22842 -0
  4. data/data/sc2ai/protocol/common.proto +59 -0
  5. data/data/sc2ai/protocol/data.proto +120 -0
  6. data/data/sc2ai/protocol/debug.proto +127 -0
  7. data/data/sc2ai/protocol/error.proto +221 -0
  8. data/data/sc2ai/protocol/query.proto +55 -0
  9. data/data/sc2ai/protocol/raw.proto +202 -0
  10. data/data/sc2ai/protocol/sc2api.proto +718 -0
  11. data/data/sc2ai/protocol/score.proto +108 -0
  12. data/data/sc2ai/protocol/spatial.proto +115 -0
  13. data/data/sc2ai/protocol/ui.proto +145 -0
  14. data/data/setup/setup.SC2Map +0 -0
  15. data/data/setup/setup.SC2Replay +0 -0
  16. data/data/stableid.json +35730 -0
  17. data/data/versions.json +554 -0
  18. data/exe/sc2ai +35 -0
  19. data/lib/docker_build/Dockerfile.ruby +74 -0
  20. data/lib/docker_build/docker-compose-base-image.yml +10 -0
  21. data/lib/docker_build/docker-compose-ladderzip.yml +9 -0
  22. data/lib/sc2ai/api/ability_id.rb +1644 -0
  23. data/lib/sc2ai/api/buff_id.rb +306 -0
  24. data/lib/sc2ai/api/data.rb +101 -0
  25. data/lib/sc2ai/api/effect_id.rb +20 -0
  26. data/lib/sc2ai/api/tech_tree.rb +83 -0
  27. data/lib/sc2ai/api/tech_tree_data.rb +2338 -0
  28. data/lib/sc2ai/api/unit_type_id.rb +2022 -0
  29. data/lib/sc2ai/api/upgrade_id.rb +310 -0
  30. data/lib/sc2ai/cli/cli.rb +175 -0
  31. data/lib/sc2ai/cli/ladderzip.rb +154 -0
  32. data/lib/sc2ai/cli/new.rb +88 -0
  33. data/lib/sc2ai/configuration.rb +145 -0
  34. data/lib/sc2ai/connection/connection_listener.rb +30 -0
  35. data/lib/sc2ai/connection/requests.rb +417 -0
  36. data/lib/sc2ai/connection/status_listener.rb +15 -0
  37. data/lib/sc2ai/connection.rb +146 -0
  38. data/lib/sc2ai/local_play/client/configurable_options.rb +115 -0
  39. data/lib/sc2ai/local_play/client.rb +159 -0
  40. data/lib/sc2ai/local_play/client_manager.rb +70 -0
  41. data/lib/sc2ai/local_play/map_file.rb +48 -0
  42. data/lib/sc2ai/local_play/match.rb +184 -0
  43. data/lib/sc2ai/overrides/array.rb +14 -0
  44. data/lib/sc2ai/overrides/async/process/child.rb +31 -0
  45. data/lib/sc2ai/overrides/kernel.rb +33 -0
  46. data/lib/sc2ai/paths.rb +294 -0
  47. data/lib/sc2ai/player/actions.rb +386 -0
  48. data/lib/sc2ai/player/debug.rb +224 -0
  49. data/lib/sc2ai/player/game_state.rb +131 -0
  50. data/lib/sc2ai/player/geometry.rb +766 -0
  51. data/lib/sc2ai/player/previous_state.rb +49 -0
  52. data/lib/sc2ai/player/units.rb +337 -0
  53. data/lib/sc2ai/player.rb +661 -0
  54. data/lib/sc2ai/ports.rb +152 -0
  55. data/lib/sc2ai/protocol/_meta_documentation.rb +39 -0
  56. data/lib/sc2ai/protocol/common_pb.rb +43 -0
  57. data/lib/sc2ai/protocol/data_pb.rb +47 -0
  58. data/lib/sc2ai/protocol/debug_pb.rb +56 -0
  59. data/lib/sc2ai/protocol/error_pb.rb +36 -0
  60. data/lib/sc2ai/protocol/extensions/color.rb +20 -0
  61. data/lib/sc2ai/protocol/extensions/point.rb +23 -0
  62. data/lib/sc2ai/protocol/extensions/point_2_d.rb +26 -0
  63. data/lib/sc2ai/protocol/extensions/position.rb +202 -0
  64. data/lib/sc2ai/protocol/extensions/power_source.rb +19 -0
  65. data/lib/sc2ai/protocol/extensions/unit.rb +489 -0
  66. data/lib/sc2ai/protocol/query_pb.rb +47 -0
  67. data/lib/sc2ai/protocol/raw_pb.rb +57 -0
  68. data/lib/sc2ai/protocol/sc2api_pb.rb +130 -0
  69. data/lib/sc2ai/protocol/score_pb.rb +40 -0
  70. data/lib/sc2ai/protocol/spatial_pb.rb +48 -0
  71. data/lib/sc2ai/protocol/ui_pb.rb +56 -0
  72. data/lib/sc2ai/unit_group/action_ext.rb +74 -0
  73. data/lib/sc2ai/unit_group/filter_ext.rb +379 -0
  74. data/lib/sc2ai/unit_group.rb +277 -0
  75. data/lib/sc2ai/version.rb +2 -1
  76. data/lib/sc2ai.rb +93 -2
  77. data/lib/templates/ladderzip/bin/ladder.tt +23 -0
  78. data/lib/templates/new/.ladderignore +20 -0
  79. data/lib/templates/new/Gemfile.tt +7 -0
  80. data/lib/templates/new/api/common.proto +59 -0
  81. data/lib/templates/new/api/data.proto +120 -0
  82. data/lib/templates/new/api/debug.proto +127 -0
  83. data/lib/templates/new/api/error.proto +221 -0
  84. data/lib/templates/new/api/query.proto +55 -0
  85. data/lib/templates/new/api/raw.proto +202 -0
  86. data/lib/templates/new/api/sc2api.proto +718 -0
  87. data/lib/templates/new/api/score.proto +108 -0
  88. data/lib/templates/new/api/spatial.proto +115 -0
  89. data/lib/templates/new/api/ui.proto +145 -0
  90. data/lib/templates/new/boot.rb.tt +6 -0
  91. data/lib/templates/new/my_bot.rb.tt +23 -0
  92. data/lib/templates/new/run_example_match.rb.tt +14 -0
  93. data/sc2ai.gemspec +80 -0
  94. metadata +344 -13
@@ -0,0 +1,766 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rumale/clustering/dbscan"
4
+ require "rumale/pairwise_metric"
5
+
6
+ module Sc2
7
+ class Player
8
+ # Holds map and geography helper functions
9
+ class Geometry
10
+ # @!attribute Holds the parent bot object
11
+ # @return [Sc2::Player] player with active connection
12
+ attr_accessor :bot
13
+
14
+ def initialize(bot)
15
+ @bot = bot
16
+ end
17
+
18
+ # Gets the map tile width. Range is 1-255.
19
+ # Effected by crop_to_playable_area
20
+ # @return [Integer]
21
+ def map_width
22
+ # bot.bot.game_info
23
+ bot.game_info.start_raw.map_size.x
24
+ end
25
+
26
+ # Gets the map tile height. Range is 1-255.
27
+ # Effected by crop_to_playable_area
28
+ # @return [Integer]
29
+ def map_height
30
+ # bot.bot.game_info
31
+ bot.game_info.start_raw.map_size.y
32
+ end
33
+
34
+ # Returns zero to map_width as range
35
+ # @return [Range] 0 to map_width
36
+ def map_range_x
37
+ 0..(map_width)
38
+ end
39
+
40
+ # Returns zero to map_height as range
41
+ # @return [Range] 0 to map_height
42
+ def map_range_y
43
+ 0..(map_height)
44
+ end
45
+
46
+ # Returns zero to map_width-1 as range
47
+ # @return [Range]
48
+ def map_tile_range_x
49
+ 0..(map_width - 1)
50
+ end
51
+
52
+ # Returns zero to map_height-1 as range
53
+ # @return [Range]
54
+ def map_tile_range_y
55
+ 0..(map_height - 1)
56
+ end
57
+
58
+ # Map Parsing functions -----
59
+
60
+ # Returns whether a x/y (integer) is placeable as per minimap image data.
61
+ # It does not say whether a position is occupied by another building.
62
+ # One pixel covers one whole block. Corrects floats on your behalf
63
+ # @param x [Float, Integer]
64
+ # @param y [Float, Integer]
65
+ # @return [Boolean] whether tile is placeable?
66
+ # @see Sc2::Player#pathable? for detecting obstructions
67
+ def placeable?(x:, y:)
68
+ parsed_placement_grid[y.to_i, x.to_i] != 0
69
+ end
70
+
71
+ # Returns a parsed placement_grid from bot.game_info.start_raw.
72
+ # Each value in [row][column] holds a boolean value represented as an integer
73
+ # It does not say whether a position is occupied by another building.
74
+ # One pixel covers one whole block. Rounds fractionated positions down.
75
+ def parsed_placement_grid
76
+ if @parsed_placement_grid.nil?
77
+ image_data = bot.game_info.start_raw.placement_grid
78
+ # Fix endian for Numo bit parser
79
+ data = image_data.data.unpack("b*").pack("B*")
80
+ @parsed_placement_grid = ::Numo::Bit.from_binary(data, [image_data.size.y, image_data.size.x])
81
+ end
82
+ @parsed_placement_grid
83
+ end
84
+
85
+ # Returns a grid where ony the expo locations are marked
86
+ def expo_placement_grid
87
+ if @expo_placement_grid.nil?
88
+ @expo_placement_grid = Numo::Bit.zeros(map_height, map_width)
89
+ expansion_points.each do |point|
90
+ x = point.x.floor
91
+ y = point.y.floor
92
+ @expo_placement_grid[(y - 2).clamp(map_tile_range_y)..(y + 2).clamp(map_tile_range_y),
93
+ (x - 2).clamp(map_tile_range_y)..(x + 2).clamp(map_tile_range_y)] = 1
94
+ end
95
+ end
96
+ @expo_placement_grid
97
+ end
98
+
99
+ # Returns a grid where powered locations are marked true
100
+ def parsed_power_grid
101
+ # Cache for based on power unit tags
102
+ cache_key = bot.power_sources.map(&:tag).sort.hash
103
+ return @parsed_power_grid[0] if !@parsed_power_grid.nil? && @parsed_power_grid[1] == cache_key
104
+
105
+ result = Numo::Bit.zeros(map_height, map_width)
106
+ power_source = bot.power_sources.first
107
+ if power_source.nil?
108
+ @parsed_power_grid = [result, cache_key]
109
+ return result
110
+ end
111
+
112
+ radius = power_source.radius
113
+ radius_tile = radius.ceil
114
+
115
+ # Keep this code-block, should we need to make power sources dynamic again:
116
+ # START: Dynamic blueprint
117
+ # # Build a blueprint and mark it everywhere we need to
118
+ # # Lets mark everything as powered with 1 and then disable non-powered with a 0
119
+ # blueprint = Numo::Bit.ones(radius.ceil * 2, radius.ceil * 2)
120
+ # #blueprint[radius_tile, radius_tile] = 0
121
+ # blueprint[(radius_tile - 1)..radius_tile, (radius_tile - 1)..radius_tile] = 0
122
+ # # Loop over top-right quadrant of a circle, so we don't have to +/- for distance calcs.
123
+ # # Additionally, we only measure if in the upper triangle, since the inner is all inside the circle.
124
+ # # Then apply to all four quadrants.
125
+ # quadrant_size = radius_tile - 1
126
+ # point_search_offsets = (0..quadrant_size).to_a.product((0..quadrant_size).to_a)
127
+ # point_search_offsets.each do |y, x|
128
+ # next if x < quadrant_size - y # Only upper Triangle
129
+ #
130
+ # dist = Math.hypot(x, y)
131
+ # if dist >= radius
132
+ # # Mark as outside x4
133
+ # blueprint[radius_tile + y, radius_tile + x] = 0
134
+ # blueprint[radius_tile + y, radius_tile - 1 - x] = 0
135
+ # blueprint[radius_tile - 1 - y, radius_tile + x] = 0
136
+ # blueprint[radius_tile - 1 - y, radius_tile - 1 - x] = 0
137
+ # end
138
+ # end
139
+ # END: Dynamic blueprint ---
140
+
141
+ # Hard-coding this shape for pylon power
142
+ # 00001111110000
143
+ # 00111111111100
144
+ # 01111111111110
145
+ # 01111111111110
146
+ # 11111111111111
147
+ # 11111111111111
148
+ # 11111100111111
149
+ # 11111100111111
150
+ # 11111111111111
151
+ # 11111111111111
152
+ # 01111111111110
153
+ # 01111111111110
154
+ # 00111111111100
155
+ # 00001111110000
156
+
157
+ # perf: Saving pre-created shape for speed (0.5ms saved) by using hardcode from .to_binary.unpack("C*")
158
+ blueprint_data = [240, 3, 255, 227, 255, 249, 127, 255, 255, 255, 255, 243, 255, 252, 255, 255, 255, 239, 255, 249, 127, 252, 15, 252, 0].pack("C*")
159
+ blueprint = ::Numo::Bit.from_binary(blueprint_data, [radius_tile * 2, radius_tile * 2])
160
+
161
+ bot.power_sources.each do |ps|
162
+ x_tile = ps.pos.x.floor
163
+ y_tile = ps.pos.y.floor
164
+ replace_start_x = (x_tile - radius_tile)
165
+ replace_end_x = (x_tile + radius_tile - 1)
166
+ replace_start_y = (y_tile - radius_tile)
167
+ replace_end_y = (y_tile + radius_tile - 1)
168
+ bp_start_x = bp_start_y = 0
169
+ bp_end_x = bp_end_y = blueprint.shape[0] - 1
170
+
171
+ # Laborious clamping if blueprint goes over edge
172
+ if replace_start_x < 0
173
+ bp_start_x += replace_start_x
174
+ replace_start_x = 0
175
+ elsif replace_end_x >= map_width
176
+ bp_end_x += map_width - replace_end_x - 1
177
+ replace_end_x = map_width - 1
178
+ end
179
+ if replace_start_y < 0
180
+ bp_start_y += replace_start_y
181
+ replace_start_y = 0
182
+ elsif replace_end_y >= map_height
183
+ bp_end_y += map_height - replace_end_y - 1
184
+ replace_end_y = map_height - 1
185
+ end
186
+
187
+ # Bitwise OR because previous pylons could overlap
188
+ result[replace_start_y..replace_end_y, replace_start_x..replace_end_x] = result[replace_start_y..replace_end_y, replace_start_x..replace_end_x] | blueprint[bp_start_y..bp_end_y, bp_start_x..bp_end_x]
189
+ end
190
+ bot.power_sources.each do |ps|
191
+ # For pylons, remove pylon location on ground
192
+ next if bot.structures.pylons[ps.tag].nil?
193
+ result[(ps.pos.y.floor - 1)..ps.pos.y.floor, (ps.pos.x.floor - 1)..ps.pos.x.floor] = 0
194
+ end
195
+ @parsed_power_grid = [result, cache_key]
196
+ result
197
+ end
198
+
199
+ # Returns whether a x/y block is powered. Only fully covered blocks are true.
200
+ # One pixel covers one whole block. Corrects float inputs on your behalf.
201
+ # @param x [Float, Integer]
202
+ # @param y [Float, Integer]
203
+ # @return [Boolean] true if location is powered
204
+ def powered?(x:, y:)
205
+ parsed_creep[y.to_i, x.to_i] != 0
206
+ end
207
+
208
+ # Returns whether a x/y block is pathable as per minimap
209
+ # One pixel covers one whole block. Corrects float inputs on your behalf.
210
+ # @param x [Float, Integer]
211
+ # @param y [Float, Integer]
212
+ # @return [Boolean] whether tile is patahble
213
+ def pathable?(x:, y:)
214
+ parsed_pathing_grid[y.to_i, x.to_i] != 0
215
+ end
216
+
217
+ # Gets the pathable areas as things stand right now in the game
218
+ # Buildings, minerals, structures, etc. all result in a nonpathable place
219
+ # @example
220
+ # parsed_pathing_grid[0,0] # reads bottom left corner
221
+ # # use helper function #pathable
222
+ # pathable?(x: 0, y: 0) # reads bottom left corner
223
+ # @return [Numo::Bit] Numo array
224
+ def parsed_pathing_grid
225
+ if bot.game_info_stale?
226
+ previous_data = bot.game_info.start_raw.pathing_grid.data
227
+ bot.refresh_game_info
228
+ # Only re-parse if binary strings don't match
229
+ clear_cached_pathing_grid if previous_data != bot.game_info.start_raw.pathing_grid.data
230
+ end
231
+
232
+ if @parsed_pathing_grid.nil?
233
+ image_data = bot.game_info.start_raw.pathing_grid
234
+ # Fix endian for Numo bit parser
235
+ data = image_data.data.unpack("b*").pack("B*")
236
+ @parsed_pathing_grid = ::Numo::Bit.from_binary(data, [image_data.size.y, image_data.size.x])
237
+ end
238
+ @parsed_pathing_grid
239
+ end
240
+
241
+ # Clears pathing-grid dependent objects like placements.
242
+ # Called when pathing grid gets updated
243
+ #
244
+ private def clear_cached_pathing_grid
245
+ @parsed_pathing_grid = nil
246
+ @_build_coordinates = {}
247
+ @_build_coordinate_tree = {}
248
+ end
249
+
250
+ # Returns the terrain height (z) at position x and y
251
+ # Granularity is per placement grid block, since this comes from minimap image data.
252
+ # @param x [Float, Integer]
253
+ # @param y [Float, Integer]
254
+ # @return [Float] z axis position between -16 and 16
255
+ def terrain_height(x:, y:)
256
+ parsed_terrain_height[y.to_i, x.to_i]
257
+ end
258
+
259
+ # Returns a parsed terrain_height from bot.game_info.start_raw.
260
+ # Each value in [row][column] holds a float value which is the z height
261
+ # @return [Numo::SFloat] Numo array
262
+ def parsed_terrain_height
263
+ if @parsed_terrain_height.nil?
264
+
265
+ image_data = bot.game_info.start_raw.terrain_height
266
+ @parsed_terrain_height = ::Numo::UInt8.from_binary(image_data.data,
267
+ [image_data.size.y, image_data.size.x])
268
+ .cast_to(Numo::SFloat)
269
+
270
+ # Values are between -16 and +16. The api values is a float height compressed to rgb range (0-255) in that range of 32.
271
+ # real_height = -16 + (value / 255) * 32
272
+ # These are the least bulk operations while still letting Numo run the loops:
273
+ @parsed_terrain_height *= (32.0 / 255.0)
274
+ @parsed_terrain_height -= 16.0
275
+ end
276
+ @parsed_terrain_height
277
+ end
278
+
279
+ # Returns one of three Integer visibility indicators at tile for x & y
280
+ # @param x [Float, Integer]
281
+ # @param y [Float, Integer]
282
+ # @return [Integer] 0=Hidden,1= Snapshot,2=Visible
283
+ def visibility(x:, y:)
284
+ parsed_visibility_grid[y.to_i, x.to_i]
285
+ end
286
+
287
+ # Returns whether the point (tile) is currently in vision
288
+ # @param x [Float, Integer]
289
+ # @param y [Float, Integer]
290
+ # @return [Boolean] true if fog is completely lifted
291
+ def map_visible?(x:, y:)
292
+ visibility(x:, y:) == 2
293
+ end
294
+
295
+ # Returns whether point (tile) has been seen before or currently visible
296
+ # @param x [Float, Integer]
297
+ # @param y [Float, Integer]
298
+ # @return [Boolean] true if partially or fully lifted fog
299
+ def map_seen?(x:, y:)
300
+ visibility(x:, y:) != 0
301
+ end
302
+
303
+ # Returns whether the point (tile) has never been seen/explored before (dark fog)
304
+ # @param x [Float, Integer]
305
+ # @param y [Float, Integer]
306
+ # @return [Boolean] true if fog of war is fully dark
307
+ def map_unseen?(x:, y:)
308
+ !map_seen?(x:, y:)
309
+ end
310
+
311
+ # Returns a parsed map_state.visibility from bot.observation.raw_data.
312
+ # Each value in [row][column] holds one of three integers (0,1,2) to flag a vision type
313
+ # @see #visibility for reading from this value
314
+ # @return [Numo::SFloat] Numo array
315
+ def parsed_visibility_grid
316
+ if @parsed_visibility_grid.nil?
317
+ image_data = bot.observation.raw_data.map_state.visibility
318
+ @parsed_visibility_grid = ::Numo::UInt8.from_binary(image_data.data,
319
+ [image_data.size.y, image_data.size.x])
320
+ end
321
+ @parsed_visibility_grid
322
+ end
323
+
324
+ # Returns whether a tile has creep on it, as per minimap
325
+ # One pixel covers one whole block. Corrects float inputs on your behalf.
326
+ # @param x [Float, Integer]
327
+ # @param y [Float, Integer]
328
+ # @return [Boolean] true if location has creep on it
329
+ def creep?(x:, y:)
330
+ parsed_creep[y.to_i, x.to_i] != 0
331
+ end
332
+
333
+ # Provides parsed minimap representation of creep spread
334
+ # @return [Numo::Bit] Numo array
335
+ def parsed_creep
336
+ if @parsed_creep.nil?
337
+ image_data = bot.observation.raw_data.map_state.creep
338
+ # Fix endian for Numo bit parser
339
+ data = image_data.data.unpack("b*").pack("B*")
340
+ @parsed_creep = ::Numo::Bit.from_binary(data, [image_data.size.y, image_data.size.x])
341
+ end
342
+ @parsed_creep
343
+ end
344
+
345
+ # TODO: Removing. Better name or more features for this? Maybe check nearest units.
346
+ # Checks map data for placeability (without querying api)
347
+ # You can manually query this instead for potential better results with
348
+ # @see Sc2::Connection::Requests#query_placements for a slow (5ms) alternative
349
+ # @return [Boolean] whether coordinate is placeable and pathable
350
+ # def can_place?(x:, y:)
351
+ # placeable?(x: x, y: y) && pathable?(x: x, y: y)
352
+ # end
353
+
354
+ # TODO: Remove this method if it has no use. Build points uses this code directly for optimization.
355
+ # Reduce the dimensions of a grid by merging cells using length x length squares.
356
+ # Merged cell keeps it's 1 value only if all merged cells are equal to 1, else 0
357
+ # @param input_grid [Numo::Bit] Bit grid like parsed_pathing_grid or parsed_placement_grid
358
+ # @param length [Integer] how many cells to merge, i.e. 3 for finding 3x3 placement
359
+ def divide_grid(input_grid, length)
360
+ height = input_grid.shape[0]
361
+ width = input_grid.shape[1]
362
+
363
+ new_height = height / length
364
+ new_width = width / length
365
+
366
+ # Assume everything is placeable. We will check and set 0's below
367
+ output_grid = Numo::Bit.ones(new_height, new_width)
368
+
369
+ # divide map into tile length and remove remainder blocks
370
+ capped_height = new_height * length
371
+ capped_width = new_width * length
372
+
373
+ # These loops are all structured this way, because of speed.
374
+ y = 0
375
+ while y < capped_height
376
+ x = 0
377
+ while x < capped_width
378
+ # We are on the bottom-left of a placement tile of Length x Length
379
+ # Check right- and upwards for any negatives and break both loops, as soon as we find one
380
+ inner_y = 0
381
+ while inner_y < length
382
+ inner_x = 0
383
+ while inner_x < length
384
+ if (input_grid[y + inner_y, x + inner_x]).zero?
385
+ output_grid[y / length, x / length] = 0
386
+ inner_y = length
387
+ break
388
+ end
389
+ inner_x += 1
390
+ end
391
+ inner_y += 1
392
+ end
393
+ # End of checking sub-cells
394
+
395
+ x += length
396
+ end
397
+ y += length
398
+ end
399
+ output_grid
400
+ end
401
+
402
+ # Gets expos and surrounding minerals
403
+ # The index is a build location for an expo and the value is a UnitGroup, which has minerals and geysers
404
+ # @example
405
+ # random_expo = expansions.keys.sample #=> Point2D
406
+ # expo_resources = geo.expansions[random_expo] #=> UnitGroup
407
+ # alive_minerals = expo_resources.minerals - neutral.minerals
408
+ # geysers = expo_resources.geysers
409
+ # @return [Hash<Api::Point2D, UnitGroup>] Location => UnitGroup of resources (minerals+geysers)
410
+ def expansions
411
+ return @expansions unless @expansions.nil?
412
+
413
+ @expansions = {}
414
+
415
+ # An array of offsets to search around the center of resource cluster for points
416
+ point_search_offsets = (-7..7).to_a.product((-7..7).to_a)
417
+ point_search_offsets.select! do |x, y|
418
+ dist = Math.hypot(x, y)
419
+ dist > 4 && dist <= 8
420
+ end
421
+
422
+ # Split resources by Z axis
423
+ resources = bot.neutral.minerals + bot.neutral.geysers
424
+ resource_group_z = resources.group_by do |resource|
425
+ resource.pos.z.round # 32 units of Y, most maps will have use 3. round to nearest.
426
+ end
427
+
428
+ # Cluster over every z level
429
+ resource_group_z.map do |z, resource_group|
430
+ # Convert group into numo array of 2d points
431
+ positions = Numo::DFloat.zeros(resource_group.size, 2)
432
+ resource_group.each_with_index do |res, index|
433
+ positions[index, 0] = res.pos.x
434
+ positions[index, 1] = res.pos.y
435
+ end
436
+ # Max 8.5 distance apart for nodes, else it's noise. At least 4 resources for an expo
437
+ analyzer = Rumale::Clustering::DBSCAN.new(eps: 8.5, min_samples: 4)
438
+ cluster_marks = analyzer.fit_predict(positions)
439
+
440
+ # for each cluster, grab those indexes to reference the mineral/gas
441
+ # then work out a placeable position based on their locations
442
+ (0..cluster_marks.max).each do |cluster_index|
443
+ clustered_resources = resource_group.select.with_index { |_res, i| cluster_marks[i] == cluster_index }
444
+ possible_points = {}
445
+
446
+ # Grab center of clustered
447
+ avg_x = clustered_resources.sum { |res| res.pos.x } / clustered_resources.size
448
+ avg_y = clustered_resources.sum { |res| res.pos.y } / clustered_resources.size
449
+ # Round average spot to nearest 0.5 point, since HQ center is at half measure (5 wide)
450
+ avg_x = avg_x.round + 0.5
451
+ avg_y = avg_y.round + 0.5
452
+
453
+ points_length = point_search_offsets.length
454
+ i = 0
455
+ while i < points_length
456
+ x = point_search_offsets[i][0] + avg_x
457
+ y = point_search_offsets[i][1] + avg_y
458
+
459
+ if !map_tile_range_x.include?(x + 1) || !map_tile_range_y.include?(y + 1)
460
+ i += 1
461
+ next
462
+ end
463
+
464
+ if parsed_placement_grid[y.floor, x.floor].zero?
465
+ i += 1
466
+ next
467
+ end
468
+
469
+ # Compare this point to each resource to ensure it's far enough away
470
+ distance_sum = 0
471
+ valid_min_distance = clustered_resources.all? do |res|
472
+ dist = Math.hypot(res.pos.x - x, res.pos.y - y)
473
+ if Sc2::UnitGroup::TYPE_GEYSER.include?(res.unit_type)
474
+ min_distance = 7
475
+ distance_sum += (dist / 7.0) * dist
476
+ else
477
+ min_distance = 6
478
+ distance_sum += dist
479
+ end
480
+ dist >= min_distance
481
+ end
482
+ possible_points[[x, y]] = distance_sum if valid_min_distance
483
+
484
+ i += 1
485
+ end
486
+ # Choose best fitting point
487
+ best_point = possible_points.keys[possible_points.values.find_index(possible_points.values.min)]
488
+ @expansions[best_point.to_p2d] = UnitGroup.new(clustered_resources)
489
+ end
490
+ end
491
+ @expansions
492
+ end
493
+
494
+ # Returns a list of 2d points for expansion build locations
495
+ # Does not contain mineral info, but the value can be checked against geo.expansions
496
+ #
497
+ # @example
498
+ # random_expo = expansion_points.sample
499
+ # expo_resources = geo.expansions[random_expo]
500
+ # @return [Array<Api::Point2D>] points where expansions can be placed
501
+ def expansion_points
502
+ expansions.keys
503
+ end
504
+
505
+ # Returns a slice of #expansions where a base hasn't been built yet
506
+ # @example
507
+ # # Lets find the nearest unoccupied expo
508
+ # expo_pos = expansions_unoccupied.keys.min { |p2d| p2d.distance_to(structures.hq.first) }
509
+ # # What minerals/geysers does it have?
510
+ # puts expansions_unoccupied[expo_pos].minerals # or expansions[expo_pos]... => UnitGroup
511
+ # puts expansions_unoccupied[expo_pos].geysers # or expansions[expo_pos]... => UnitGroup
512
+ # @return [Hash<Api::Point2D], UnitGroup] Location => UnitGroup of resources (minerals+geysers)
513
+ def expansions_unoccupied
514
+ taken_bases = bot.structures.hq.map { |hq| hq.pos.to_p2d } + bot.enemy.structures.hq.map { |hq| hq.pos.to_p2d }
515
+ remaining_points = expansion_points - taken_bases
516
+ expansions.slice(*remaining_points)
517
+ end
518
+
519
+ # Gets buildable point grid for squares of size, i.e. 3 = 3x3 placements
520
+ # Uses pathing grid internally, to ignore taken positions
521
+ # Does not query the api and is generally fast.
522
+ # @param length [Integer] length of the building, 2 for depot/pylon, 3 for rax/gate
523
+ # @param on_creep [Boolean] whether this build locatin should be on creep
524
+ def build_coordinates(length:, on_creep: false, in_power: false)
525
+ length = 1 if length < 1
526
+ @_build_coordinates ||= {}
527
+ cache_key = [length, on_creep].hash
528
+ return @_build_coordinates[cache_key] if !@_build_coordinates[cache_key].nil? && !bot.game_info_stale?
529
+
530
+ result = []
531
+ input_grid = parsed_pathing_grid & parsed_placement_grid & ~expo_placement_grid
532
+ input_grid = parsed_creep & input_grid if on_creep
533
+ input_grid = parsed_power_grid & input_grid if in_power
534
+
535
+ # Dimensions
536
+ height = input_grid.shape[0]
537
+ width = input_grid.shape[1]
538
+
539
+ # divide map into tile length and remove remainder blocks
540
+ capped_height = height / length * length
541
+ capped_width = width / length * length
542
+
543
+ # Build points are in center of square, i.e. 1.5 inwards for a 3x3 building
544
+ offset_to_inside = length / 2.0
545
+
546
+ # Note, these loops are structured for speed
547
+ y = 0
548
+ while y < capped_height
549
+ x = 0
550
+ while x < capped_width
551
+ # We are on the bottom-left of a placement tile of Length x Length
552
+ # Check right- and upwards for any negatives and break both loops, as soon as we find one
553
+ valid_position = true
554
+ inner_y = 0
555
+ while inner_y < length
556
+ inner_x = 0
557
+ while inner_x < length
558
+ if (input_grid[y + inner_y, x + inner_x]).zero?
559
+ # break sub-cells check and don't save position
560
+ valid_position = false
561
+ inner_y = length
562
+ break
563
+ end
564
+ inner_x += 1
565
+ end
566
+ inner_y += 1
567
+ end
568
+ # End of checking sub-cells
569
+
570
+ result << [x + offset_to_inside, y + offset_to_inside] if valid_position
571
+ x += length
572
+ end
573
+ y += length
574
+ end
575
+ @_build_coordinates[cache_key] = result
576
+ end
577
+
578
+ # Gets a buildable location for a square of length, near target. Chooses from random amount of nearest locations.
579
+ # For robustness, it is advised to set `random` to, i.e. 3, to allow choosing the 3 nearest possible places, should one location be blocked.
580
+ # For zerg, the buildable locations are only on creep.
581
+ # Internally creates a kdtree for building locations based on pathable, placeable and creep
582
+ # @param length [Integer] length of the building, 2 for depot/pylon, 3 for rax/gate
583
+ # @param target [Api::Unit, Sc2::Position] near where to find a placement
584
+ # @param random [Integer] number of nearest points to randomly choose from. 1 for nearest point.
585
+ # @return [Api::Point2D, nil] buildable location, nil if no buildable location found
586
+ def build_placement_near(length:, target:, random: 1)
587
+ target = target.pos if target.is_a? Api::Unit
588
+ random = 1 if random.to_i.negative?
589
+ length = 1 if length < 1
590
+ on_creep = bot.race == Api::Race::Zerg
591
+
592
+ coordinates = build_coordinates(length:, on_creep:)
593
+ @_build_coordinate_tree ||= {}
594
+ cache_key = [length, on_creep].hash
595
+ if @_build_coordinate_tree[cache_key].nil?
596
+ @_build_coordinate_tree[cache_key] = Kdtree.new(
597
+ coordinates.each_with_index.map { |coords, index| coords + [index] }
598
+ )
599
+ end
600
+ nearest = @_build_coordinate_tree[cache_key].nearestk(target.x, target.y, random)
601
+ return nil if nearest.nil?
602
+
603
+ coordinates[nearest.sample].to_p2d
604
+ end
605
+
606
+ # Protoss ------
607
+
608
+ # Draws a grid within a unit (pylon/prisms) radius, then selects points which are placeable
609
+ # @param source [Api::Unit] either a pylon or a prism
610
+ # @param unit_type_id [Api::Unit] optionally, the unit you wish to place. Stalkers are widest, so use default nil for a mixed composition warp
611
+ # @return [Array<Api::Point2D>] an array of 2d points where theoretically placeable
612
+ def warp_points(source:, unit_type_id: nil)
613
+ # power source needed
614
+ power_source = bot.power_sources.find { |ps| source.tag == ps.tag }
615
+ return [] if power_source.nil?
616
+
617
+ # hardcoded unit radius, otherwise only obtainable by owning a unit already
618
+ unit_type_id = Api::UnitTypeId::STALKER if unit_type_id.nil?
619
+ target_radius = case unit_type_id
620
+ when Api::UnitTypeId::STALKER
621
+ 0.625
622
+ when Api::UnitTypeId::HIGHTEMPLAR, Api::UnitTypeId::DARKTEMPLAR
623
+ 0.375
624
+ else
625
+ 0.5 # Adept, zealot, sentry, etc.
626
+ end
627
+ unit_width = target_radius * 2
628
+
629
+ # power source's inner and outer radius
630
+ outer_radius = power_source.radius
631
+ # Can not spawn on-top of pylon
632
+ inner_radius = (source.unit_type == Api::UnitTypeId::PYLON) ? source.radius : 0
633
+
634
+ # Make a grid of circles packed in triangle formation, covering the power field
635
+ points = []
636
+ y_increment = Math.sqrt(Math.hypot(unit_width, unit_width / 2.0))
637
+ offset_row = false
638
+ # noinspection RubyMismatchedArgumentType # rbs fixed in future patch
639
+ ((source.pos.y - outer_radius + target_radius)..(source.pos.y + outer_radius - target_radius)).step(y_increment) do |y|
640
+ ((source.pos.x - outer_radius + target_radius)..(source.pos.x + outer_radius - target_radius)).step(unit_width) do |x|
641
+ x += target_radius if offset_row
642
+ points << Api::Point2D[x, y]
643
+ end
644
+ offset_row = !offset_row
645
+ end
646
+
647
+ # Select only grid points inside the outer source and outside the inner source
648
+ points.select! do |grid_point|
649
+ gp_distance = source.pos.distance_to(grid_point)
650
+ gp_distance > inner_radius + target_radius && gp_distance + target_radius < outer_radius
651
+ end
652
+
653
+ # Find X amount of near units within the radius and subtract their overlap in radius with points
654
+ # we arbitrarily decided that a pylon will no be surrounded by more than 50 units
655
+ # We add 2.75 above, which is the fattest ground unit (nexus @ 2.75 radius)
656
+ units_in_pylon_range = bot.all_units.nearest_to(pos: source.pos, amount: 50)
657
+ .select_in_circle(point: source.pos, radius: outer_radius + 2.75)
658
+
659
+ # Reject warp points which overlap with units inside
660
+ points.reject! do |point|
661
+ # Find units which overlap with our warp points
662
+ units_in_pylon_range.find do |unit|
663
+ xd = (unit.pos.x - point.x).abs
664
+ yd = (unit.pos.y - point.y).abs
665
+ intersect_distance = target_radius + unit.radius
666
+ next false if xd > intersect_distance || yd > intersect_distance
667
+
668
+ Math.hypot(xd, yd) < intersect_distance
669
+ end
670
+ end
671
+
672
+ # Select only warp points which are on placeable tiles
673
+ points.reject! do |point|
674
+ left = (point.x - target_radius).floor.clamp(map_tile_range_x)
675
+ right = (point.x + target_radius).floor.clamp(map_tile_range_x)
676
+ top = (point.y + target_radius).floor.clamp(map_tile_range_y)
677
+ bottom = (point.y - target_radius).floor.clamp(map_tile_range_y)
678
+
679
+ unplaceable = false
680
+ x = left
681
+ while x <= right
682
+ break if unplaceable
683
+ y = bottom
684
+ while y <= top
685
+ unplaceable = !placeable?(x: x, y: y)
686
+ break if unplaceable
687
+ y += 1
688
+ end
689
+ x += 1
690
+ end
691
+ unplaceable
692
+ end
693
+
694
+ points
695
+ end
696
+
697
+ # Geometry helpers ---
698
+
699
+ # Finds points in a straight line.
700
+ # In a line, on the angle of source->target point, starting at source+offset, in increments find points on the line up to max distance
701
+ # @param source [Sc2::Position] location from which we go
702
+ # @param target [Sc2::Position] location towards which we go
703
+ # @param offset [Float] how far from source to start
704
+ # @param increment [Float] how far apart to gets, i.e. increment = unit.radius*2 to space units in a line
705
+ # @param count [Integer] number of points to retrieve
706
+ # @return [Array<Api::Point2D>] points up to a max of count
707
+ def points_nearest_linear(source:, target:, offset: 0.0, increment: 1.0, count: 1)
708
+ # Normalized angle
709
+ dx = (target.x - source.x)
710
+ dy = (target.y - source.y)
711
+ dist = Math.hypot(dx, dy)
712
+ dx /= dist
713
+ dy /= dist
714
+
715
+ # Set start position and offset if necessary
716
+ start_x = source.x
717
+ start_y = source.y
718
+ unless offset.zero?
719
+ start_x += (dx * offset)
720
+ start_y += (dy * offset)
721
+ end
722
+
723
+ # For count times, increment our radius and multiply by angle to get the new point
724
+ points = []
725
+ i = 1
726
+ while i < count
727
+ radius = increment * i
728
+ point = Api::Point2D[
729
+ start_x + (dx * radius),
730
+ start_y + (dy * radius)
731
+ ]
732
+
733
+ # ensure we're on the map
734
+ break unless map_range_x.cover?(point.x) && map_range_y.cover?(point.x)
735
+
736
+ points << point
737
+ i += 1
738
+ end
739
+
740
+ points
741
+ end
742
+
743
+ # Gets a random point near a location with a positive/negative offset applied to both x and y
744
+ # @example
745
+ # Randomly randomly adjust both x and y by a range of -3.5 or +3.5
746
+ # geo.point_random_near(point: structures.hq.first, offset: 3.5)
747
+ # @param pos [Sc2::Location]
748
+ # @param offset [Float]
749
+ # @return [Api::Point2D]
750
+ def point_random_near(pos:, offset: 1.0)
751
+ pos.random_offset(offset)
752
+ end
753
+
754
+ # @param pos [Sc2::Location]
755
+ # @param radius [Float]
756
+ # @return [Api::Point2D]
757
+ def point_random_on_circle(pos:, radius: 1.0)
758
+ angle = rand(0..360) * Math::PI / 180.0
759
+ Api::Point2D[
760
+ pos.x + (Math.sin(angle) * radius),
761
+ pos.y + (Math.cos(angle) * radius)
762
+ ]
763
+ end
764
+ end
765
+ end
766
+ end