sc2ai 0.0.0.pre → 0.0.3

Sign up to get free protection for your applications and to get access to all the features.
Files changed (93) hide show
  1. checksums.yaml +4 -4
  2. data/data/data.json +1 -0
  3. data/data/data_readable.json +22946 -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 +37900 -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 +1951 -0
  23. data/lib/sc2ai/api/buff_id.rb +316 -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 +82 -0
  27. data/lib/sc2ai/api/tech_tree_data.rb +2342 -0
  28. data/lib/sc2ai/api/unit_type_id.rb +2074 -0
  29. data/lib/sc2ai/api/upgrade_id.rb +312 -0
  30. data/lib/sc2ai/cli/cli.rb +177 -0
  31. data/lib/sc2ai/cli/ladderzip.rb +173 -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. metadata +353 -9
@@ -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_power_grid[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