├── cannon-lover ├── run.bat ├── run.py ├── __init__.py ├── base_bot.py └── cannon_lover_bot.py ├── LICENSE ├── README.md └── .gitignore /cannon-lover/run.bat: -------------------------------------------------------------------------------- 1 | python run.py 2 | cmd /k -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Hannes Rydén 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /cannon-lover/run.py: -------------------------------------------------------------------------------- 1 | import sc2, sys 2 | from __init__ import run_ladder_game 3 | from sc2 import Race, Difficulty 4 | from sc2.player import Bot, Computer, Human 5 | import random 6 | 7 | # Load bot 8 | from cannon_lover_bot import CannonLoverBot 9 | bot = Bot(Race.Protoss, CannonLoverBot()) 10 | 11 | # Start game 12 | if __name__ == '__main__': 13 | if "--LadderServer" in sys.argv: 14 | # Ladder game started by LadderManager 15 | print("Starting ladder game...") 16 | run_ladder_game(bot) 17 | else: 18 | # Local game 19 | print("Starting local game...") 20 | map_name = random.choice(["(2)16-BitLE", "(2)AcidPlantLE", "(2)CatalystLE", "(2)DreamcatcherLE", "(2)LostandFoundLE", "(2)RedshiftLE", "(4)DarknessSanctuaryLE"]) 21 | #map_name = random.choice(["ProximaStationLE", "NewkirkPrecinctTE", "OdysseyLE", "MechDepotLE", "AscensiontoAiurLE", "BelShirVestigeLE"]) 22 | #map_name = "(2)16-BitLE" 23 | sc2.run_game(sc2.maps.get(map_name), [ 24 | #Human(Race.Terran), 25 | bot, 26 | Computer(Race.Random, Difficulty.VeryHard) # CheatInsane VeryHard 27 | ], realtime=False, save_replay_as="Example.SC2Replay") 28 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # StarCraft 2 Bots 2 | Various StarCraft 2 bots coded in Python. 3 | 4 | ## Requirements 5 | * [Python 3.6+](https://www.python.org/downloads/) 6 | * [python-sc2](https://github.com/Dentosal/python-sc2) (```pip install sc2```) 7 | 8 | ## How to run 9 | 1. Install the requirements and download the repository. 10 | 2. Open "run.bat" in the bot's folder (Windows only), or type ```python run.py``` in the console (Mac/Linux). 11 | 12 | ## Bots 13 | * **CannonLover** (*P*) - Cannon rushes natural expansion of opponent, then switches to macro zealot/stalker/sentry/colossus with some army movement and micro. 14 | 15 | ## Features 16 | ### CannonLover 17 | * Cannon rush logic that starts at natural expansion and progresses towards enemy main. 18 | * On 4-player maps or if too long time passes, it switches to macro strategy. 19 | * Macro strategy expands aggressively, upgrades and builds zealots/stalkers/sentries/immortals/colossus/observers. 20 | * Army units compare nearby friendly vs enemy army size before taking an engagement (measured by health + shield). 21 | * Remembers enemy units no longer in sight to know when it can engage, and to avoid dying on ramps. 22 | * Evasive blink stalker micro when stalker is taking damage. 23 | * Counts number of enemy roaches/marauders/stalkers to decide if it should build immortals or colossus. 24 | * Custom BaseBot class with various helper functions. 25 | * Overridden self.do() that increases performance by queuing up commands which are then executed by self.execute_order_queue() at the end of the on_step() function. 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | .pytest_cache/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | db.sqlite3 58 | 59 | # Flask stuff: 60 | instance/ 61 | .webassets-cache 62 | 63 | # Scrapy stuff: 64 | .scrapy 65 | 66 | # Sphinx documentation 67 | docs/_build/ 68 | 69 | # PyBuilder 70 | target/ 71 | 72 | # Jupyter Notebook 73 | .ipynb_checkpoints 74 | 75 | # pyenv 76 | .python-version 77 | 78 | # celery beat schedule file 79 | celerybeat-schedule 80 | 81 | # SageMath parsed files 82 | *.sage.py 83 | 84 | # Environments 85 | .env 86 | .venv 87 | env/ 88 | venv/ 89 | ENV/ 90 | env.bak/ 91 | venv.bak/ 92 | 93 | # Spyder project settings 94 | .spyderproject 95 | .spyproject 96 | 97 | # Rope project settings 98 | .ropeproject 99 | 100 | # mkdocs documentation 101 | /site 102 | 103 | # mypy 104 | .mypy_cache/ 105 | *.SC2Replay 106 | -------------------------------------------------------------------------------- /cannon-lover/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | import argparse 3 | 4 | import sys 5 | import asyncio 6 | import logging 7 | import aiohttp 8 | 9 | import sc2 10 | from sc2 import Race, Difficulty 11 | from sc2.player import Bot, Computer 12 | 13 | from sc2.sc2process import SC2Process 14 | from sc2.client import Client 15 | 16 | # Run ladder game 17 | # This lets python-sc2 connect to a LadderManager game: https://github.com/Cryptyc/Sc2LadderServer 18 | # Based on: https://github.com/Dentosal/python-sc2/blob/master/examples/run_external.py 19 | def run_ladder_game(bot): 20 | # Load command line arguments 21 | parser = argparse.ArgumentParser() 22 | parser.add_argument('--GamePort', type=int, nargs="?", help='Game port') 23 | parser.add_argument('--StartPort', type=int, nargs="?", help='Start port') 24 | parser.add_argument('--LadderServer', type=str, nargs="?", help='Ladder server') 25 | parser.add_argument('--ComputerOpponent', type=str, nargs="?", help='Computer opponent') 26 | parser.add_argument('--ComputerRace', type=str, nargs="?", help='Computer race') 27 | parser.add_argument('--ComputerDifficulty', type=str, nargs="?", help='Computer difficulty') 28 | parser.add_argument('--OpponentId', type=str, nargs="?", help='Opponent ID') 29 | args, unknown = parser.parse_known_args() 30 | 31 | if args.LadderServer == None: 32 | host = "127.0.0.1" 33 | else: 34 | host = args.LadderServer 35 | 36 | host_port = args.GamePort 37 | lan_port = args.StartPort 38 | 39 | # Versus Computer doesn't work yet 40 | computer_opponent = False 41 | if args.ComputerOpponent: 42 | computer_opponent = True 43 | computer_race = args.ComputerRace 44 | computer_difficulty = args.ComputerDifficulty 45 | 46 | # Port config 47 | ports = [lan_port + p for p in range(1,6)] 48 | 49 | portconfig = sc2.portconfig.Portconfig() 50 | portconfig.shared = ports[0] # Not used 51 | portconfig.server = [ports[1], ports[2]] 52 | portconfig.players = [[ports[3], ports[4]]] 53 | 54 | # Join ladder game 55 | g = join_ladder_game( 56 | host=host, 57 | port=host_port, 58 | players=[bot], 59 | realtime=False, 60 | portconfig=portconfig 61 | ) 62 | 63 | # Run it 64 | result = asyncio.get_event_loop().run_until_complete(g) 65 | print(result) 66 | 67 | # Modified version of sc2.main._join_game to allow custom host and port, and to not spawn an additional sc2process (thanks to alkurbatov for fix) 68 | async def join_ladder_game(host, port, players, realtime, portconfig, save_replay_as=None, step_time_limit=None, game_time_limit=None): 69 | ws_url = "ws://{}:{}/sc2api".format(host, port) 70 | ws_connection = await aiohttp.ClientSession().ws_connect(ws_url, timeout=120) 71 | client = Client(ws_connection) 72 | 73 | try: 74 | result = await sc2.main._play_game(players[0], client, realtime, portconfig, step_time_limit, game_time_limit) 75 | if save_replay_as is not None: 76 | await client.save_replay(save_replay_as) 77 | await client.leave() 78 | await client.quit() 79 | except ConnectionAlreadyClosed: 80 | logging.error(f"Connection was closed before the game ended") 81 | return None 82 | finally: 83 | ws_connection.close() 84 | 85 | return result 86 | -------------------------------------------------------------------------------- /cannon-lover/base_bot.py: -------------------------------------------------------------------------------- 1 | 2 | import random, math, time 3 | 4 | import sc2 5 | from sc2 import Race, Difficulty 6 | from sc2.constants import * 7 | from sc2.player import Bot, Computer 8 | 9 | 10 | # This fix is required for the queued order system to work correctly (self.execute_order_queue()) 11 | import itertools 12 | FLOAT_DIGITS = 8 13 | EPSILON = 10**(-FLOAT_DIGITS) 14 | def eq(self, other): 15 | #assert isinstance(other, tuple) 16 | if not isinstance(other, tuple): 17 | return False 18 | return all(abs(a - b) < EPSILON for a, b in itertools.zip_longest(self, other, fillvalue=0)) 19 | sc2.position.Pointlike.__eq__ = eq 20 | 21 | 22 | class BaseBot(sc2.BotAI): 23 | under_construction = {} 24 | timer = None 25 | order_queue = [] 26 | 27 | remembered_enemy_units = [] 28 | remembered_enemy_units_by_tag = {} 29 | remembered_friendly_units_by_tag = {} 30 | 31 | def reset_timer(self): 32 | self.timer = time.time() 33 | 34 | 35 | def get_timer(self): 36 | if self.timer: 37 | return "%s" % (time.time() - self.timer) 38 | else: 39 | return "Timer not started" 40 | 41 | # Cancel buildings in construction that are under attack 42 | async def cancel_buildings(self): 43 | # Loop through all buildings that are under construction 44 | for building in self.units.structure.not_ready: 45 | # If we're not tracking this building, make sure to track it 46 | if building.tag not in self.under_construction: 47 | self.under_construction[building.tag] = {} 48 | self.under_construction[building.tag]["last_health"] = building.health 49 | 50 | # If health is low, and has dropped since last frame, cancel it! 51 | if building.health < 100 and building.health < self.under_construction[building.tag]["last_health"]: 52 | await self.do(building(CANCEL)) 53 | else: 54 | self.under_construction[building.tag]["last_health"] = building.health 55 | 56 | # Returns current game time in seconds. Source: https://github.com/Dentosal/python-sc2/issues/29#issuecomment-365874073 57 | def get_game_time(self): 58 | return self.state.game_loop*0.725*(1/16) 59 | 60 | # Find enemy natural expansion location 61 | async def find_enemy_natural(self): 62 | closest = None 63 | distance = math.inf 64 | for el in self.expansion_locations: 65 | def is_near_to_expansion(t): 66 | return t.position.distance_to(el) < self.EXPANSION_GAP_THRESHOLD 67 | 68 | if is_near_to_expansion(sc2.position.Point2(self.enemy_start_locations[0])): 69 | continue 70 | 71 | #if any(map(is_near_to_expansion, )): 72 | # already taken 73 | # continue 74 | 75 | d = await self._client.query_pathing(self.enemy_start_locations[0], el) 76 | if d is None: 77 | continue 78 | 79 | if d < distance: 80 | distance = d 81 | closest = el 82 | 83 | return closest 84 | 85 | 86 | # Custom select_worker() to also check if path is blocked 87 | async def select_worker(self, pos, force=False): 88 | if not self.workers.exists: 89 | return None 90 | 91 | # Find worker closest to pos, but make sure to only choose a worker that is gathering (to not cancel orders) 92 | worker = None 93 | for unit in self.workers.prefer_close_to(pos): 94 | if unit.is_idle or self.has_order([HARVEST_GATHER, HARVEST_RETURN, MOVE], unit): 95 | worker = unit 96 | break 97 | 98 | if worker is None: 99 | return None 100 | 101 | # Check if path is blocked 102 | distance = await self._client.query_pathing(worker.position, pos) 103 | if distance is None: 104 | # Path is blocked, so return random worker 105 | return self.workers.random 106 | else: 107 | # Path not blocked 108 | return worker 109 | 110 | # Custom overridden build() to use select_worker() instead, and also try a random alternative if failing 111 | async def build(self, building, near, max_distance=20, unit=None, random_alternative=False, placement_step=2): 112 | """Build a building.""" 113 | 114 | if isinstance(near, sc2.unit.Unit): 115 | near = near.position.to2 116 | elif near is not None: 117 | near = near.to2 118 | 119 | is_valid_location = False 120 | p = None 121 | 122 | p = await self.find_placement(building, near.rounded, max_distance, random_alternative, placement_step) 123 | if p is None: 124 | p = await self.find_placement(building, near.rounded, max_distance, True, placement_step) 125 | if p is None: 126 | return sc2.data.ActionResult.CantFindPlacementLocation 127 | 128 | unit = unit or await self.select_worker(p) 129 | 130 | if unit is None: 131 | return sc2.data.ActionResult.Error 132 | 133 | return await self.do(unit.build(building, p)) 134 | 135 | # Give an order to unit(s) 136 | async def order(self, units, order, target=None, silent=True): 137 | if type(units) != list: 138 | unit = units 139 | await self.do(unit(order, target=target)) 140 | else: 141 | for unit in units: 142 | await self.do(unit(order, target=target)) 143 | 144 | async def do(self, action): 145 | #print("Custom do") 146 | #assert self.can_afford(action) 147 | #if not self.can_afford(action): 148 | # return 149 | 150 | self.order_queue.append(action) #await self._client.actions(action, game_data=self._game_data) 151 | 152 | #cost = self._game_data.calculate_ability_cost(action.ability) 153 | #self.minerals -= cost.minerals 154 | #self.vespene -= cost.vespene 155 | #print("Custom do done") 156 | 157 | # Warp-in a unit nearby location from warpgate 158 | async def warp_in(self, unit, location, warpgate): 159 | if isinstance(location, sc2.unit.Unit): 160 | location = location.position.to2 161 | elif location is not None: 162 | location = location.to2 163 | 164 | x = random.randrange(-8,8) 165 | y = random.randrange(-8,8) 166 | 167 | placement = sc2.position.Point2((location.x+x,location.y+y)) 168 | 169 | action = warpgate.warp_in(unit, placement) 170 | error = await self._client.actions(action, game_data=self._game_data) 171 | 172 | if not error: 173 | cost = self._game_data.calculate_ability_cost(action.ability) 174 | self.minerals -= cost.minerals 175 | self.vespene -= cost.vespene 176 | return None 177 | else: 178 | return error 179 | 180 | # Execute all orders in self.order_queue and reset it 181 | async def execute_order_queue(self): 182 | await self._client.actions(self.order_queue, game_data=self._game_data) 183 | self.order_queue = [] # Reset order queue 184 | 185 | 186 | async def train(self, unit_type, building): 187 | if self.can_afford(unit_type): #and await self.has_ability(unit_type, building): 188 | await self.do(building.train(unit_type)) 189 | 190 | async def can_train(self, unit_type, building): 191 | return await self.has_ability(unit_type, building) 192 | 193 | async def upgrade(self, upgrade_type, building): 194 | if self.can_afford(upgrade_type) and await self.has_ability(upgrade_type, building): 195 | await self.do(building(upgrade_type)) 196 | 197 | async def can_upgrade(self, upgrade_type, building): 198 | return await self.has_ability(upgrade_type, building) 199 | 200 | # Check if a unit has an ability available (also checks upgrade costs??) 201 | async def has_ability(self, ability, unit): 202 | abilities = await self.get_available_abilities(unit) 203 | if ability in abilities: 204 | return True 205 | else: 206 | return False 207 | 208 | # Check if a unit has a specific order. Supports multiple units/targets. Returns unit count. 209 | def has_order(self, orders, units): 210 | if type(orders) != list: 211 | orders = [orders] 212 | 213 | count = 0 214 | 215 | if type(units) == sc2.unit.Unit: 216 | unit = units 217 | if len(unit.orders) >= 1 and unit.orders[0].ability.id in orders: 218 | count += 1 219 | else: 220 | for unit in units: 221 | if len(unit.orders) >= 1 and unit.orders[0].ability.id in orders: 222 | count += 1 223 | 224 | return count 225 | 226 | # Check if a unit has a specific target. Supports multiple units/targets. Returns unit count. 227 | def has_target(self, targets, units): 228 | if type(targets) != list: 229 | targets = [targets] 230 | 231 | count = 0 232 | 233 | if type(units) == sc2.unit.Unit: 234 | unit = units 235 | if len(unit.orders) == 1 and unit.orders[0].target in targets: 236 | count += 1 237 | else: 238 | for unit in units: 239 | if len(unit.orders) == 1 and unit.orders[0].target in targets: 240 | count += 1 241 | 242 | return count 243 | 244 | # Custom distribute_workers() to not touch idle workers (otherwise cannon builders will be affected) 245 | async def distribute_workers(self): 246 | """Distributes workers across all the bases taken.""" 247 | 248 | expansion_locations = self.expansion_locations 249 | owned_expansions = self.owned_expansions 250 | worker_pool = [] 251 | 252 | for location, townhall in owned_expansions.items(): 253 | workers = self.workers.closer_than(20, location) 254 | actual = townhall.assigned_harvesters 255 | ideal = townhall.ideal_harvesters 256 | excess = actual-ideal 257 | if actual > ideal: 258 | worker_pool.extend(workers.random_group_of(min(excess, len(workers)))) 259 | continue 260 | for g in self.geysers: 261 | workers = self.workers.closer_than(5, g) 262 | actual = g.assigned_harvesters 263 | ideal = g.ideal_harvesters 264 | excess = actual - ideal 265 | if actual > ideal: 266 | worker_pool.extend(workers.random_group_of(min(excess, len(workers)))) 267 | continue 268 | 269 | for g in self.geysers: 270 | actual = g.assigned_harvesters 271 | ideal = g.ideal_harvesters 272 | deficit = ideal - actual 273 | 274 | for x in range(0, deficit): 275 | if worker_pool: 276 | w = worker_pool.pop() 277 | if len(w.orders) == 1 and w.orders[0].ability.id in [AbilityId.HARVEST_RETURN]: 278 | await self.do(w.move(g)) 279 | await self.do(w.return_resource(queue=True)) 280 | elif len(w.orders) == 1 and w.orders[0].ability.id in [AbilityId.HARVEST_GATHER]: 281 | await self.do(w.gather(g)) 282 | 283 | for location, townhall in owned_expansions.items(): 284 | actual = townhall.assigned_harvesters 285 | ideal = townhall.ideal_harvesters 286 | 287 | deficit = ideal - actual 288 | for x in range(0, deficit): 289 | if worker_pool: 290 | w = worker_pool.pop() 291 | mf = self.state.mineral_field.closest_to(townhall) 292 | if len(w.orders) == 1 and w.orders[0].ability.id in [AbilityId.HARVEST_RETURN]: 293 | await self.do(w.move(townhall)) 294 | await self.do(w.return_resource(queue=True)) 295 | await self.do(w.gather(mf, queue=True)) 296 | elif len(w.orders) == 1 and w.orders[0].ability.id in [AbilityId.HARVEST_GATHER]: 297 | await self.do(w.gather(mf)) 298 | 299 | async def worker_split(self): 300 | for worker in self.workers: 301 | closest_mineral_patch = self.state.mineral_field.closest_to(worker) 302 | await self.do(worker.gather(closest_mineral_patch)) 303 | #await self.order(worker, HARVEST_GATHER, closest_mineral_patch) 304 | 305 | 306 | # Remember enemy units' last position, even though they're not seen anymore 307 | def remember_enemy_units(self): 308 | # Every 60 seconds, clear all remembered units (to clear out killed units) 309 | #if round(self.get_game_time() % 60) == 0: 310 | # self.remembered_enemy_units_by_tag = {} 311 | 312 | # Look through all currently seen units and add them to list of remembered units (override existing) 313 | for unit in self.known_enemy_units: 314 | unit.is_known_this_step = True 315 | self.remembered_enemy_units_by_tag[unit.tag] = unit 316 | 317 | # Convert to an sc2 Units object and place it in self.remembered_enemy_units 318 | self.remembered_enemy_units = sc2.units.Units([], self._game_data) 319 | for tag, unit in list(self.remembered_enemy_units_by_tag.items()): 320 | # Make unit.is_seen = unit.is_visible 321 | if unit.is_known_this_step: 322 | unit.is_seen = unit.is_visible # There are known structures that are not visible 323 | unit.is_known_this_step = False # Set to false for next step 324 | else: 325 | unit.is_seen = False 326 | 327 | # Units that are not visible while we have friendly units nearby likely don't exist anymore, so delete them 328 | if not unit.is_seen and self.units.closer_than(7, unit).exists: 329 | del self.remembered_enemy_units_by_tag[tag] 330 | continue 331 | 332 | self.remembered_enemy_units.append(unit) 333 | 334 | # Remember friendly units' previous state, so we can see if they're taking damage 335 | def remember_friendly_units(self): 336 | for unit in self.units: 337 | unit.is_taking_damage = False 338 | 339 | # If we already remember this friendly unit 340 | if unit.tag in self.remembered_friendly_units_by_tag: 341 | health_old = self.remembered_friendly_units_by_tag[unit.tag].health 342 | shield_old = self.remembered_friendly_units_by_tag[unit.tag].shield 343 | 344 | # Compare its health/shield since last step, to find out if it has taken any damage 345 | if unit.health < health_old or unit.shield < shield_old: 346 | unit.is_taking_damage = True 347 | 348 | self.remembered_friendly_units_by_tag[unit.tag] = unit 349 | 350 | -------------------------------------------------------------------------------- /cannon-lover/cannon_lover_bot.py: -------------------------------------------------------------------------------- 1 | # Inspired by: https://github.com/Dentosal/python-sc2/blob/master/examples/cannon_rush.py 2 | import random, math, asyncio 3 | 4 | import sc2 5 | from sc2 import Race, Difficulty 6 | from sc2.constants import * 7 | from sc2.player import Bot, Computer 8 | 9 | from base_bot import BaseBot 10 | 11 | # TODO: Better micro for first cannon builder 12 | # TODO: Bug, workers hunt enemies too far out 13 | # TODO: Better scouting when no enemy units are known (focus on enemy_start_locations) 14 | 15 | 16 | class CannonLoverBot(BaseBot): 17 | cannon_start_distance = 35 # Distance of first pylon/cannon from enemy base (towards natural expansion) 18 | cannon_advancement_rate = 6 # Distance units to cover per pylon towards enemy base 19 | cannons_to_pylons_ratio = 2 # How many cannons to build per pylon at cannon_location 20 | sentry_ratio = 0.15 # Sentry ratio 21 | stalker_ratio = 0.6 #0.7 # Stalker/Zealot ratio (1 = only stalkers) 22 | units_to_ignore = [DRONE, SCV, PROBE, EGG, LARVA, OVERLORD, OVERSEER, OBSERVER, BROODLING, INTERCEPTOR, MEDIVAC, CREEPTUMOR, CREEPTUMORBURROWED, CREEPTUMORQUEEN, CREEPTUMORMISSILE] 23 | army_size_minimum = 20 # Minimum number of army units before attacking. 24 | enemy_threat_distance = 50 # Enemy min distance from base before going into panic mode. 25 | max_worker_count = 70 # Max number of workers to build 26 | max_cannon_count = 15 # Max number of cannons 27 | gateways_per_nexus = 2 # Number of gateways per nexus 28 | 29 | strategy = "early_game" # Set to "late_game" to skip cannon rush 30 | cannon_location = None 31 | start_location = None 32 | enemy_start_location = None 33 | #attack_target = None 34 | has_sent_workers = False 35 | iteration = 0 36 | 37 | 38 | # This is run each game step 39 | async def on_step(self, iteration): 40 | # Store iteration 41 | self.iteration = iteration 42 | 43 | # On first game step, run start logic 44 | if iteration == 0: 45 | await self.on_game_start() 46 | 47 | # Remember seen enemy units and previous state of friendly units 48 | self.remember_enemy_units() 49 | self.remember_friendly_units() 50 | 51 | # Basic logic 52 | await self.find_cannon_location() # Find next build location for cannons (and pylons) 53 | await self.manage_bases() # Manage bases (train workers etc, but also base defense) 54 | await self.cancel_buildings() # Make sure to cancel buildings under construction that are under attack 55 | 56 | # Change strategy to late game if above 3 minutes or if banking minerals 57 | if self.strategy == "early_game" and (self.get_game_time() / 60 > 3 or self.minerals > 800): 58 | await self.chat_send("Changing to late-game strategy") 59 | self.strategy = "late_game" 60 | 61 | # Run strategy 62 | if self.strategy == "early_game": 63 | await self.early_game_strategy() 64 | elif self.strategy == "late_game": 65 | await self.late_game_strategy() 66 | elif self.strategy == "panic": 67 | await self.panic_strategy() 68 | 69 | # Worker and stalker micro and movement 70 | await self.move_workers() 71 | await self.move_army() 72 | 73 | # Execute queued commands 74 | await self.execute_order_queue() 75 | 76 | # Only run once at game start 77 | async def on_game_start(self): 78 | # Say hello! 79 | await self.chat_send("(probe)(pylon)(cannon)(cannon)(gg)") 80 | 81 | # Save base locations for later 82 | self.start_location = self.units(NEXUS).first.position 83 | self.enemy_natural = await self.find_enemy_natural() 84 | 85 | # Perform worker split 86 | await self.worker_split() 87 | 88 | # If 4-player map, skip cannon rush 89 | if len(self.enemy_start_locations) > 1: 90 | await self.chat_send("Not a 2-player map, skipping cannon rush :(") 91 | self.strategy = "late_game" 92 | else: 93 | # 2-player map, so we know enemy start location 94 | self.enemy_start_location = self.enemy_start_locations[0] 95 | 96 | #self.strategy = "late_game" # Force late-game for testing 97 | 98 | 99 | # Find next location for cannons/pylons 100 | async def find_cannon_location(self): 101 | if self.units(PHOTONCANNON).amount > self.max_cannon_count: 102 | # Stop making cannons after we reached self.max_cannon_count 103 | self.cannon_location = None 104 | return 105 | elif self.strategy == "late_game" and (not self.enemy_start_location or not self.units(PYLON).closer_than(self.cannon_start_distance+5, self.enemy_start_location).exists): 106 | # Also stop making cannons if we're in late-game and still have no pylon near enemy base 107 | self.cannon_location = None 108 | return 109 | elif self.enemy_start_location: 110 | # Only if enemy start location is known 111 | target = self.enemy_start_location 112 | approach_from = self.enemy_natural #self.game_info.map_center 113 | else: 114 | # We have no idea where enemy is. Skip cannons rush. 115 | self.cannon_location = None 116 | return 117 | 118 | # Find a good distance from enemy base (start further out and slowly close in) 119 | distance = self.cannon_start_distance-(self.units(PYLON).closer_than(self.cannon_start_distance+5, target).amount*self.cannon_advancement_rate) 120 | if distance < 0: 121 | distance = 0 122 | 123 | self.cannon_location = target.towards(approach_from, distance) #random.randrange(distance, distance+5) #random.randrange(20, 30) 124 | 125 | 126 | async def manage_bases(self): 127 | # Do some logic for each nexus 128 | for nexus in self.units(NEXUS).ready: 129 | # Train workers until at nexus max (+4) 130 | if self.workers.amount < self.max_worker_count and nexus.noqueue: # and nexus.assigned_harvesters < nexus.ideal_harvesters+2 : 131 | if self.can_afford(PROBE) and self.supply_used < 198: 132 | await self.do(nexus.train(PROBE)) 133 | 134 | # Always chronoboost when possible 135 | await self.handle_chronoboost(nexus) 136 | 137 | # Idle workers near nexus should always be mining (we want to allow idle workers near cannons in enemy base) 138 | if self.workers.idle.closer_than(50, nexus).exists: 139 | worker = self.workers.idle.closer_than(50, nexus).first 140 | await self.do(worker.gather(self.state.mineral_field.closest_to(nexus))) 141 | 142 | # Worker defense: If enemy unit is near nexus, attack with a nearby workers 143 | # TODO: If up to 3 enemies, just attack with workers. If more, escape with workers from home and change mode to defense. 144 | nearby_enemies = self.known_enemy_units.not_structure.filter(lambda unit: not unit.is_flying).closer_than(30, nexus).prefer_close_to(nexus) 145 | if nearby_enemies.amount >= 1 and nearby_enemies.amount <= 10 and self.workers.exists: 146 | #if nearby_enemies.amount <= 4: 147 | # TODO: Escape if too many enemies 148 | 149 | # We have nearby enemies, so attack them with a worker 150 | workers = self.workers.prefer_close_to(nearby_enemies.first).take(nearby_enemies.amount*2, False) 151 | 152 | for worker in workers: 153 | #if not self.has_order(ATTACK, worker): 154 | await self.do(worker.attack(nearby_enemies.closer_than(30, nexus).closest_to(worker))) 155 | 156 | #worker = self.workers.closest_to(nearby_enemies.first) 157 | #if worker: 158 | # await self.do(worker.attack(nearby_enemies.first)) 159 | else: 160 | # No nearby enemies, so make sure to return all workers to base 161 | for worker in self.workers.closer_than(50, nexus): 162 | if len(worker.orders) == 1 and worker.orders[0].ability.id in [ATTACK]: 163 | await self.do(worker.gather(self.state.mineral_field.closest_to(nexus))) 164 | 165 | 166 | # Panic mode: Change cannon_location to nexus if we see many enemy units nearby 167 | if self.strategy == "early_game": 168 | # TODO: Actually count enemies in early game and detect rush 169 | num_nearby_enemy_structures = self.remembered_enemy_units.structure.closer_than(self.enemy_threat_distance, nexus).amount 170 | num_nearby_enemy_units = self.remembered_enemy_units.not_structure.closer_than(self.enemy_threat_distance, nexus).amount 171 | min_defensive_cannons = num_nearby_enemy_structures + max(num_nearby_enemy_units-1, 0) 172 | if (num_nearby_enemy_structures > 0 or num_nearby_enemy_units > 2) and self.units(PHOTONCANNON).closer_than(20, nexus).amount < min_defensive_cannons: 173 | self.cannon_location = nexus.position.towards(self.get_game_center_random(), random.randrange(5, 15)) #random.randrange(20, 30) 174 | self.strategy = "panic" 175 | await self.chat_send("That was scary! I'm going into panic mode...") 176 | 177 | elif self.strategy == "panic": 178 | self.strategy = "late_game" 179 | await self.chat_send("Everything should be fine now. Let's macro!") 180 | 181 | 182 | # Chronoboost (CB) management 183 | async def handle_chronoboost(self, nexus): 184 | if await self.has_ability(EFFECT_CHRONOBOOSTENERGYCOST, nexus) and nexus.energy >= 50: 185 | # Always CB Warpgate research first 186 | if self.units(CYBERNETICSCORE).ready.exists: 187 | cybernetics = self.units(CYBERNETICSCORE).first 188 | if not cybernetics.noqueue and not cybernetics.has_buff(CHRONOBOOSTENERGYCOST): 189 | await self.do(nexus(EFFECT_CHRONOBOOSTENERGYCOST, cybernetics)) 190 | return # Don't CB anything else this step 191 | 192 | # Blink is also important 193 | if self.units(TWILIGHTCOUNCIL).ready.exists: 194 | twilight = self.units(TWILIGHTCOUNCIL).first 195 | if not twilight.noqueue and not twilight.has_buff(CHRONOBOOSTENERGYCOST): 196 | await self.do(nexus(EFFECT_CHRONOBOOSTENERGYCOST, twilight)) 197 | return # Don't CB anything else this step 198 | 199 | # Next, focus on Forge 200 | if self.units(FORGE).ready.exists: 201 | forge = self.units(FORGE).first 202 | if not forge.noqueue and not forge.has_buff(CHRONOBOOSTENERGYCOST): 203 | await self.do(nexus(EFFECT_CHRONOBOOSTENERGYCOST, forge)) 204 | return # Don't CB anything else this step 205 | 206 | # Next, prioritize CB on gates 207 | for gateway in (self.units(GATEWAY).ready | self.units(WARPGATE).ready): 208 | if not gateway.has_buff(CHRONOBOOSTENERGYCOST): 209 | await self.do(nexus(EFFECT_CHRONOBOOSTENERGYCOST, gateway)) 210 | return # Don't CB anything else this step 211 | 212 | # Otherwise CB nexus 213 | if not nexus.has_buff(CHRONOBOOSTENERGYCOST): 214 | await self.do(nexus(EFFECT_CHRONOBOOSTENERGYCOST, nexus)) 215 | 216 | # Build cannons (and more pylons) at self.cannon_location (usually in enemy base). 217 | async def build_cannons(self): 218 | # If we have no cannon location (e.g. 4-player map), just skip building cannons 219 | if not self.cannon_location: 220 | return 221 | 222 | # Don't cancel orders for workers already on their way to build 223 | if self.has_order([PROTOSSBUILD_PHOTONCANNON, PROTOSSBUILD_PYLON], self.workers): #.closer_than(50, self.cannon_location) 224 | return 225 | 226 | num_cannons = self.units(PHOTONCANNON).ready.closer_than(15, self.cannon_location).amount + self.already_pending(PHOTONCANNON) 227 | num_pylons = self.units(PYLON).ready.filter(lambda unit: unit.shield > 0).closer_than(15, self.cannon_location).amount + self.already_pending(PYLON) 228 | 229 | # Keep the ratio between cannons as pylons 230 | if num_cannons < num_pylons * self.cannons_to_pylons_ratio: 231 | if self.can_afford(PHOTONCANNON) and self.units(FORGE).ready.exists: 232 | #await self.build(PHOTONCANNON, near=self.cannon_location) 233 | pylon = self.units(PYLON).closer_than(10, self.cannon_location).ready.prefer_close_to(self.cannon_location) 234 | if pylon.exists: 235 | await self.build(PHOTONCANNON, near=pylon.first) #, unit=self.select_builder() 236 | else: 237 | if self.can_afford(PYLON): 238 | await self.build(PYLON, near=self.cannon_location) #, unit=self.select_builder() 239 | 240 | 241 | # Opening strategy for early game 242 | async def early_game_strategy(self): 243 | nexus = self.units(NEXUS).first # We only have one nexus in early game 244 | 245 | # Send a worker to enemy base early on (just once) 246 | if not self.has_sent_workers: 247 | await self.do(self.workers.random.move(self.cannon_location)) 248 | self.has_sent_workers = True 249 | 250 | # Build one pylon at home 251 | elif not self.units(PYLON).closer_than(20, nexus).exists and not self.already_pending(PYLON): 252 | if self.can_afford(PYLON): 253 | await self.build(PYLON, near=nexus.position.towards(self.game_info.map_center, 10)) #self.get_game_center_random() 254 | 255 | # Build forge at home 256 | elif not self.units(FORGE).exists and not self.already_pending(FORGE): 257 | pylon = self.units(PYLON).ready 258 | if pylon.exists: 259 | if self.can_afford(FORGE): 260 | await self.build(FORGE, near=pylon.closest_to(nexus)) 261 | 262 | # Send an extra worker to front-line 263 | #elif self.workers.closer_than(50, self.cannon_location).amount < 2 and not self.has_order(MOVE, self.workers): 264 | # self.has_sent_workers = False 265 | 266 | 267 | # Start building cannons in enemy base (and more pylons) 268 | else: 269 | await self.scout_cheese() 270 | 271 | await self.build_cannons() 272 | 273 | 274 | # Panic strategy for all-in defense 275 | async def panic_strategy(self): 276 | nexus = self.units(NEXUS).first # We likely only have one nexus in early game 277 | 278 | # Make sure we have at least one pylon 279 | if not self.units(PYLON).exists and not self.already_pending(PYLON): 280 | if self.can_afford(PYLON): 281 | await self.build(PYLON, near=self.get_base_build_location(nexus)) 282 | 283 | # Make sure forge still exists... 284 | if not self.units(FORGE).exists and not self.already_pending(FORGE): 285 | pylon = self.units(PYLON).ready 286 | if pylon.exists: 287 | if self.can_afford(FORGE): 288 | await self.build(FORGE, near=pylon.closest_to(nexus)) 289 | 290 | # Send an extra worker to front-line 291 | #elif self.workers.closer_than(50, self.cannon_location).amount < 2 and not self.has_order(MOVE, self.workers): 292 | # self.has_sent_workers = False 293 | 294 | # Start building cannons in enemy base (and more pylons) 295 | else: 296 | await self.build_cannons() 297 | 298 | 299 | # Strategy for late game, which prioritizes unit production and upgrades rather than cannons 300 | async def late_game_strategy(self): 301 | nexus = self.units(NEXUS).random 302 | if not nexus: 303 | return 304 | 305 | gateways = self.units(GATEWAY) | self.units(WARPGATE) 306 | 307 | # We might have multiple bases, so distribute workers between them (not every game step) 308 | if self.iteration % 10 == 0: 309 | await self.distribute_workers() 310 | 311 | # If game time is greater than 2 min, make sure to always scout with one worker 312 | if self.get_game_time() > 120: 313 | await self.scout() 314 | 315 | # Make sure to expand in late game (every 2.5 minutes) 316 | expand_every = 2.5 * 60 # Seconds 317 | prefered_base_count = 1 + int(math.floor(self.get_game_time() / expand_every)) 318 | prefered_base_count = max(prefered_base_count, 2) # Take natural ASAP (i.e. minimum 2 bases) 319 | current_base_count = self.units(NEXUS).ready.filter(lambda unit: unit.ideal_harvesters >= 10).amount # Only count bases as active if they have at least 10 ideal harvesters (will decrease as it's mined out) 320 | 321 | # Vespene gases per nexus 322 | if self.enemy_start_location: 323 | # 2-player map. Just build 1 gas per nexus, as we start with cannon rush so need more minerals. 324 | prefered_gas_count = round(1 * self.units(NEXUS).amount) 325 | else: 326 | # 4-player map. Build 1.5 gas per nexus. 327 | prefered_gas_count = round(1.5 * self.units(NEXUS).amount) 328 | 329 | # Also add an extra expansion if minerals get too high 330 | #if self.minerals > 800: 331 | # prefered_base_count += 1 332 | 333 | # Make sure we have at least one pylon near nexus before expanding 334 | if not self.units(PYLON).exists and not self.already_pending(PYLON): 335 | if self.can_afford(PYLON): 336 | await self.build(PYLON, near=nexus) 337 | 338 | #print(str(self.units(NEXUS).ready.filter(lambda unit: unit.ideal_harvesters >= 10).amount) + " / " + str(prefered_base_count)) 339 | elif current_base_count < prefered_base_count and not self.already_pending(NEXUS) and await self.can_take_expansion(): 340 | if self.can_afford(NEXUS): 341 | await self.expand_now() 342 | 343 | # Keep building Pylons (until 200 supply cap) 344 | elif self.supply_left <= 6 and self.already_pending(PYLON) < 2 and self.supply_cap < 200: # 6 / 2 345 | if self.can_afford(PYLON): 346 | await self.build(PYLON, near=self.get_base_build_location(nexus, min_distance=5)) 347 | 348 | # Make sure forge still exists... 349 | elif not self.units(FORGE).exists and not self.already_pending(FORGE): 350 | if self.can_afford(FORGE): 351 | await self.build(FORGE, near=self.get_base_build_location(self.units(NEXUS).random)) 352 | 353 | # Always build a cannon in mineral line for defense 354 | elif self.units(PHOTONCANNON).closer_than(10, nexus).amount < 1: 355 | if self.units(PYLON).ready.closer_than(5, nexus).amount < 1: 356 | if self.can_afford(PYLON) and not self.already_pending(PYLON): 357 | await self.build(PYLON, near=nexus) 358 | else: 359 | if self.can_afford(PHOTONCANNON) and not self.already_pending(PHOTONCANNON): 360 | await self.build(PHOTONCANNON, near=nexus.position.towards(self.game_info.map_center, random.randrange(-10,-1))) 361 | 362 | # Take gases (1 per nexus) 363 | elif self.units(ASSIMILATOR).amount < prefered_gas_count and not self.already_pending(ASSIMILATOR): 364 | if self.can_afford(ASSIMILATOR): 365 | for gas in self.state.vespene_geyser.closer_than(20.0, nexus): 366 | if not self.units(ASSIMILATOR).closer_than(1.0, gas).exists and self.can_afford(ASSIMILATOR): 367 | worker = self.select_build_worker(gas.position, force=True) 368 | await self.do(worker.build(ASSIMILATOR, gas)) 369 | 370 | # Build 1 gateway to start with 371 | elif gateways.ready.amount < 1 and not self.already_pending(GATEWAY): 372 | if self.can_afford(GATEWAY): 373 | await self.build(GATEWAY, near=self.get_base_build_location(self.units(NEXUS).first)) 374 | 375 | # Build a Cybernetics Core (requires Gateway) 376 | elif not self.units(CYBERNETICSCORE).exists and self.units(GATEWAY).ready.exists and not self.already_pending(CYBERNETICSCORE): 377 | if self.can_afford(CYBERNETICSCORE): 378 | await self.build(CYBERNETICSCORE, near=self.get_base_build_location(self.units(NEXUS).first)) 379 | 380 | # Keep making more gateways 381 | elif gateways.amount < self.units(NEXUS).amount * self.gateways_per_nexus and self.already_pending(GATEWAY) < 2: 382 | if self.can_afford(GATEWAY): 383 | await self.build(GATEWAY, near=self.get_base_build_location(nexus)) 384 | 385 | # For late game, also build Robotics Facility 386 | elif self.units(CYBERNETICSCORE).ready.exists and not self.units(ROBOTICSFACILITY).exists and not self.already_pending(ROBOTICSFACILITY): 387 | if self.can_afford(ROBOTICSFACILITY): 388 | await self.build(ROBOTICSFACILITY, near=self.get_base_build_location(nexus)) 389 | return 390 | 391 | # For even later game, also build Robotics Bay 392 | elif self.units(ROBOTICSFACILITY).ready.exists and not self.units(ROBOTICSBAY).exists and not self.already_pending(ROBOTICSBAY): 393 | if self.can_afford(ROBOTICSBAY): 394 | await self.build(ROBOTICSBAY, near=self.get_base_build_location(nexus)) 395 | return 396 | 397 | else: 398 | # Make sure to always train army units from gateways/warpgates 399 | await self.train_army() 400 | 401 | # With the remaining money, go for upgrades 402 | await self.handle_upgrades() 403 | 404 | # And keep building cannons :) 405 | await self.build_cannons() 406 | 407 | async def can_take_expansion(self): 408 | # Must have a valid exp location 409 | location = await self.get_next_expansion() 410 | if not location: 411 | return False 412 | 413 | # Must not have enemies nearby 414 | if self.remembered_enemy_units.closer_than(10, location).exists: 415 | return False 416 | 417 | # Must be able to find a valid building position 418 | if self.can_afford(NEXUS): 419 | position = await self.find_placement(NEXUS, location.rounded, max_distance=10, random_alternative=False, placement_step=1) 420 | if not position: 421 | return False 422 | 423 | return True 424 | 425 | 426 | async def scout_cheese(self): 427 | scout = None 428 | nexus = self.units(NEXUS).first 429 | 430 | # Check if we already have a scout (a worker with PATROL order) 431 | for worker in self.workers: 432 | if self.has_order([PATROL], worker): 433 | scout = worker 434 | 435 | # If we don't have a scout, select one 436 | if not scout: 437 | scout = self.workers.closest_to(nexus) 438 | await self.order(scout, PATROL, self.find_random_cheese_location()) 439 | return 440 | 441 | # Basic avoidance: If enemy is too close, go back to nexus 442 | nearby_enemy_units = self.known_enemy_units.filter(lambda unit: unit.type_id not in self.units_to_ignore).closer_than(10, scout) 443 | if nearby_enemy_units.exists: 444 | await self.order(scout, PATROL, nexus) 445 | return 446 | 447 | # When we get close enough to our target location, change target 448 | target = sc2.position.Point2((scout.orders[0].target.x, scout.orders[0].target.y)) 449 | if scout.distance_to(target) < 6: 450 | await self.order(scout, PATROL, self.find_random_cheese_location()) 451 | return 452 | 453 | def find_random_cheese_location(self, max_distance=40, min_distance=20): 454 | location = None 455 | while not location or location.distance_to(self.start_location) > max_distance or location.distance_to(self.start_location) < min_distance: 456 | x = random.randrange(0, self.game_info.pathing_grid.width) 457 | y = random.randrange(0, self.game_info.pathing_grid.height) 458 | 459 | location = sc2.position.Point2((x,y)) 460 | return location 461 | 462 | async def scout(self): 463 | scout = None 464 | 465 | # Check if we already have a scout (a worker with PATROL order) 466 | for worker in self.workers: 467 | if self.has_order([PATROL], worker): 468 | scout = worker 469 | 470 | # If we don't have a scout, select one, and order it to move to random exp 471 | if not scout: 472 | random_exp_location = random.choice(list(self.expansion_locations.keys())) 473 | scout = self.workers.closest_to(self.start_location) 474 | 475 | if not scout: 476 | return 477 | 478 | await self.order(scout, PATROL, random_exp_location) 479 | return 480 | 481 | # Basic avoidance: If enemy is too close, go to map center 482 | nearby_enemy_units = self.known_enemy_units.filter(lambda unit: unit.type_id not in self.units_to_ignore).closer_than(10, scout) 483 | if nearby_enemy_units.exists: 484 | await self.order(scout, PATROL, self.game_info.map_center) 485 | return 486 | 487 | # We're close enough, so change target 488 | target = sc2.position.Point2((scout.orders[0].target.x, scout.orders[0].target.y)) 489 | if scout.distance_to(target) < 10: 490 | random_exp_location = random.choice(list(self.expansion_locations.keys())) 491 | await self.order(scout, PATROL, random_exp_location) 492 | return 493 | 494 | 495 | 496 | # Train/warp-in army units 497 | async def train_army(self): 498 | # Start building colossus whenever possible 499 | for robotics in self.units(ROBOTICSFACILITY).ready.noqueue: 500 | # Always have one observer out (mainly to gain high ground vision) 501 | if self.units(OBSERVER).ready.amount < 1: 502 | await self.train(OBSERVER, robotics) 503 | return 504 | 505 | # If we can research extended thermal lance, and already have a colossus out, do it 506 | elif await self.can_upgrade(RESEARCH_EXTENDEDTHERMALLANCE, robotics) and self.units(COLOSSUS).ready.amount >= 1: 507 | await self.upgrade(RESEARCH_EXTENDEDTHERMALLANCE, robotics) 508 | return 509 | 510 | # Else, just train colossus/immortals 511 | elif self.units(ROBOTICSBAY).ready.exists: 512 | enemy_units = self.remembered_enemy_units 513 | has_mostly_marauders = enemy_units(MARAUDER).amount > 0 and enemy_units(MARAUDER).amount > enemy_units(MARINE).amount 514 | has_mostly_mech = enemy_units(HELLION).amount > 0 and enemy_units(HELLION).amount > enemy_units(MARINE).amount 515 | has_mostly_stalkers = enemy_units(STALKER).amount > 0 and enemy_units(STALKER).amount * 1.5 > enemy_units(ZEALOT).amount 516 | has_mostly_roaches = enemy_units(ROACH).amount > 0 and enemy_units(ROACH).amount > enemy_units(HYDRALISK).amount and enemy_units(ROACH).amount * 2 > enemy_units(ZERGLING).amount 517 | has_too_many_flying = enemy_units(VIKINGFIGHTER).amount > 3 or enemy_units(MUTALISK).amount > 3 or enemy_units(VOIDRAY).amount > 3 or enemy_units(PHOENIX).amount > 3 518 | 519 | # Depending on enemy's unit composition, build either immortal or colossus 520 | if has_mostly_marauders or has_mostly_mech or has_mostly_stalkers or has_mostly_roaches or has_too_many_flying: 521 | await self.train(IMMORTAL, robotics) 522 | else: 523 | await self.train(COLOSSUS, robotics) 524 | 525 | # Don't train anything else until robo units are built 526 | return 527 | 528 | 529 | rally_location = self.get_rally_location() 530 | 531 | # Train at Gateways 532 | for gateway in self.units(GATEWAY).ready: 533 | # Set gateway rally 534 | #await self.do(gateway(RALLY_BUILDING, rally_location)) 535 | 536 | if gateway.noqueue: 537 | if await self.has_ability(MORPH_WARPGATE, gateway): 538 | #if self.can_afford(MORPH_WARPGATE): 539 | await self.do(gateway(MORPH_WARPGATE)) 540 | return 541 | elif self.supply_used < 198 and self.supply_left >= 2: 542 | # Train 75% Stalkers and 25% Zealots 543 | if self.can_afford(STALKER) and self.can_afford(ZEALOT) and self.can_afford(SENTRY): 544 | rand = random.random() 545 | if rand < self.sentry_ratio and self.units(CYBERNETICSCORE).ready.exists: 546 | await self.do(gateway.train(SENTRY)) 547 | return 548 | elif rand <= self.stalker_ratio and self.units(CYBERNETICSCORE).ready.exists: 549 | await self.do(gateway.train(STALKER)) 550 | return 551 | else: 552 | await self.do(gateway.train(ZEALOT)) 553 | return 554 | 555 | # Warp-in from Warpgates 556 | for warpgate in self.units(WARPGATE).ready: 557 | # We check for WARPGATETRAIN_ZEALOT to see if warpgate is ready to warp in 558 | if await self.has_ability(WARPGATETRAIN_ZEALOT, warpgate) and self.supply_used < 198 and self.supply_left >= 2: 559 | # Always warp in zealots if banking minerals 560 | if self.minerals > 400 and self.vespene < 100: 561 | await self.warp_in(ZEALOT, rally_location, warpgate) 562 | # Otherwise, train units depending on the ratio 563 | elif self.can_afford(STALKER) and self.can_afford(ZEALOT) and self.can_afford(SENTRY): 564 | rand = random.random() 565 | if rand <= self.sentry_ratio and self.units(CYBERNETICSCORE).ready.exists: 566 | await self.warp_in(SENTRY, rally_location, warpgate) 567 | return 568 | elif rand <= self.stalker_ratio and self.units(CYBERNETICSCORE).ready.exists: 569 | await self.warp_in(STALKER, rally_location, warpgate) 570 | return 571 | else: 572 | await self.warp_in(ZEALOT, rally_location, warpgate) 573 | return 574 | 575 | 576 | # Handle upgrades. 577 | async def handle_upgrades(self): 578 | # Prioritize warp-gate research 579 | if self.units(CYBERNETICSCORE).ready.exists: 580 | cybernetics = self.units(CYBERNETICSCORE).first 581 | if cybernetics.noqueue and await self.has_ability(RESEARCH_WARPGATE, cybernetics): 582 | if self.can_afford(RESEARCH_WARPGATE): 583 | await self.do(cybernetics(RESEARCH_WARPGATE)) 584 | return 585 | 586 | # Build Twilight Council (requires Cybernetics Core) 587 | if not self.units(TWILIGHTCOUNCIL).exists and not self.already_pending(TWILIGHTCOUNCIL): 588 | if self.can_afford(TWILIGHTCOUNCIL) and self.units(CYBERNETICSCORE).ready.exists: 589 | await self.build(TWILIGHTCOUNCIL, near=self.get_base_build_location(self.units(NEXUS).first)) 590 | return 591 | 592 | if not self.units(TWILIGHTCOUNCIL).ready.exists: 593 | return 594 | twilight = self.units(TWILIGHTCOUNCIL).first 595 | 596 | # Research Blink and Charge at Twilight 597 | # Temporary bug workaround: Don't go further unless we can afford blink 598 | if not self.can_afford(RESEARCH_BLINK): 599 | return 600 | 601 | if twilight.noqueue: 602 | if await self.has_ability(RESEARCH_BLINK, twilight): 603 | if self.can_afford(RESEARCH_BLINK): 604 | await self.do(twilight(RESEARCH_BLINK)) 605 | return 606 | elif await self.has_ability(RESEARCH_CHARGE, twilight): 607 | if self.can_afford(RESEARCH_CHARGE): 608 | await self.do(twilight(RESEARCH_CHARGE)) 609 | return 610 | 611 | # Must have a forge to continue upgrades 612 | if not self.units(FORGE).ready.exists: 613 | return 614 | forge = self.units(FORGE).first 615 | 616 | # Only if we're not upgrading anything yet 617 | if forge.noqueue: 618 | # Go through each weapon, armor and shield upgrade and check if we can research it, and if so, do it 619 | for upgrade_level in range(1, 4): 620 | upgrade_weapon_id = getattr(sc2.constants, "FORGERESEARCH_PROTOSSGROUNDWEAPONSLEVEL" + str(upgrade_level)) 621 | upgrade_armor_id = getattr(sc2.constants, "FORGERESEARCH_PROTOSSGROUNDARMORLEVEL" + str(upgrade_level)) 622 | shield_armor_id = getattr(sc2.constants, "FORGERESEARCH_PROTOSSSHIELDSLEVEL" + str(upgrade_level)) 623 | if await self.has_ability(upgrade_weapon_id, forge): 624 | if self.can_afford(upgrade_weapon_id): 625 | await self.do(forge(upgrade_weapon_id)) 626 | return 627 | elif await self.has_ability(upgrade_armor_id, forge): 628 | if self.can_afford(upgrade_armor_id): 629 | await self.do(forge(upgrade_armor_id)) 630 | return 631 | elif await self.has_ability(shield_armor_id, forge): 632 | if self.can_afford(shield_armor_id): 633 | await self.do(forge(shield_armor_id)) 634 | return 635 | 636 | 637 | # Micro for workers 638 | async def move_workers(self): 639 | # Make workers flee from enemy cannon 640 | for worker in self.workers: 641 | if self.known_enemy_units.structure.ready.filter(lambda unit: unit.type_id in [PHOTONCANNON]).closer_than(9, worker): 642 | if not self.has_order(MOVE, worker): 643 | await self.do(worker.move(worker.position.towards(self.start_location, 4))) 644 | 645 | # Make low health cannon builders flee from melee enemies 646 | if self.cannon_location: 647 | for worker in self.workers.closer_than(40, self.cannon_location): 648 | if worker.shield < 10 and self.known_enemy_units.closer_than(4, worker).not_structure.filter(lambda unit: not unit.is_flying).exists: 649 | if not self.has_order(MOVE, worker): 650 | # We have nearby enemy. Run home! 651 | #await self.do(worker.gather(self.state.mineral_field.closest_to(self.units(NEXUS).first))) #Do mineral walk at home base to escape. 652 | await self.do(worker.move(worker.position.towards(self.start_location, 4))) 653 | 654 | 655 | 656 | # Movement and micro for army 657 | async def move_army(self): 658 | army_units = self.units(STALKER).ready | self.units(ZEALOT).ready | self.units(OBSERVER).ready | self.units(COLOSSUS).ready | self.units(IMMORTAL).ready | self.units(SENTRY).ready 659 | army_count = army_units.amount 660 | home_location = self.start_location 661 | focus_fire_target = None 662 | attack_random_exp = False 663 | attack_location = None 664 | 665 | # Determine attack location 666 | if army_count < self.army_size_minimum: 667 | # We have less than self.army_size_minimum army in total. Just gather at rally point 668 | attack_location = self.get_rally_location() 669 | elif self.remembered_enemy_units.filter(lambda unit: unit.type_id not in self.units_to_ignore).exists: 670 | # We have large enough army and have seen an enemy. Attack closest enemy to home 671 | attack_location = self.remembered_enemy_units.filter(lambda unit: unit.type_id not in self.units_to_ignore).closest_to(home_location).position 672 | else: 673 | # We have not seen an enemy 674 | #if random.random() < 0.8: 675 | # Try move to random enemy start location 80% of time 676 | # attack_location = random.choice(self.enemy_start_locations) #self.enemy_start_locations[0] 677 | #else: 678 | # As a last resort, scout different expansions with army units 679 | attack_random_exp = True 680 | 681 | 682 | # Micro for each individual army unit 683 | for unit in army_units: 684 | has_blink = False 685 | has_guardianshield = False 686 | if unit.type_id == STALKER: 687 | has_blink = await self.has_ability(EFFECT_BLINK_STALKER, unit) # Do we have blink? 688 | elif unit.type_id == SENTRY: 689 | has_guardianshield = await self.has_ability(GUARDIANSHIELD_GUARDIANSHIELD, unit) 690 | 691 | # Find nearby enemy units 692 | nearby_enemy_units = self.remembered_enemy_units.not_structure.filter(lambda unit: unit.type_id not in self.units_to_ignore).closer_than(15, unit) 693 | 694 | # If we don't have any nearby enemies 695 | if not nearby_enemy_units.exists: 696 | # If we don't have an attack order, cast one now 697 | if not self.has_order(ATTACK, unit) or (self.known_enemy_units.exists and not self.has_target(attack_location, unit)): 698 | if attack_random_exp: 699 | # If we're attacking a random exp, find one now 700 | random_exp_location = random.choice(list(self.expansion_locations.keys())) 701 | await self.do(unit.attack(random_exp_location)) 702 | #print("Attack random exp") 703 | elif unit.distance_to(attack_location) > 10: 704 | await self.do(unit.attack(attack_location)) 705 | #print("Attack no enemy nearby") 706 | 707 | # Blink towards attack location 708 | #elif has_blink: 709 | # await self.order(unit, EFFECT_BLINK_STALKER, unit.orders[0].target) 710 | 711 | continue # Do no further micro 712 | 713 | # Calculate friendly vs enemy army value 714 | friendly_army_value = self.friendly_army_value(unit, 10) #20 715 | enemy_army_value = self.enemy_army_value(nearby_enemy_units.closest_to(unit), 10) #30 716 | army_advantage = friendly_army_value - enemy_army_value 717 | #army_advantage = 0 718 | 719 | # If our shield is low, escape a little backwards 720 | if unit.is_taking_damage and unit.shield < 20 and unit.type_id not in [ZEALOT]: 721 | escape_location = unit.position.towards(home_location, 4) 722 | if has_blink: 723 | # Stalkers can blink 724 | await self.order(unit, EFFECT_BLINK_STALKER, escape_location) 725 | else: 726 | # Others can move normally 727 | if not self.has_order(MOVE, unit): 728 | await self.do(unit.move(escape_location)) 729 | 730 | continue 731 | 732 | # Do we have an army advantage? 733 | if army_advantage > 0: 734 | # We have a larger army. Engage enemy 735 | attack_position = nearby_enemy_units.closest_to(unit).position 736 | 737 | # If not already attacking, attack 738 | if not self.has_order(ATTACK, unit) or not self.has_target(attack_position, unit): 739 | await self.do(unit.attack(attack_position)) 740 | 741 | # Activate guardian shield for sentries (if enemy army value is big enough) 742 | if has_guardianshield and enemy_army_value > 200: 743 | await self.order(unit, GUARDIANSHIELD_GUARDIANSHIELD) 744 | else: 745 | # We have a smaller army, so run back home! 746 | if has_blink: 747 | # Stalkers can blink 748 | await self.order(unit, EFFECT_BLINK_STALKER, home_location) 749 | else: 750 | # Others can move normally 751 | if not self.has_order(MOVE, unit): 752 | await self.do(unit.move(home_location)) 753 | 754 | 755 | 756 | def get_rally_location(self): 757 | if self.units(PYLON).ready.exists: 758 | if self.cannon_location: 759 | rally_location = self.units(PYLON).ready.closest_to(self.cannon_location).position 760 | else: 761 | rally_location = self.units(PYLON).ready.closest_to(self.game_info.map_center).position 762 | else: 763 | rally_location = self.start_location 764 | return rally_location 765 | 766 | # Approximate army value by adding unit health+shield 767 | def friendly_army_value(self, position, distance=10): 768 | value = 0 769 | 770 | for unit in self.units.not_structure.filter(lambda unit: unit.type_id not in self.units_to_ignore).closer_than(distance, position): 771 | value += unit.health + unit.shield 772 | 773 | # Count nearby cannons 774 | for unit in self.units(PHOTONCANNON).closer_than(10, position): 775 | value += unit.health # Skip shield, to not overestimate 776 | 777 | # Count nearby bunkers 778 | for unit in self.units(BUNKER).ready.closer_than(10, position): 779 | value += unit.health 780 | 781 | # Count nearby spine crawlers 782 | for unit in self.units(SPINECRAWLER).ready.closer_than(10, position): 783 | value += unit.health 784 | 785 | return value 786 | 787 | # Approximate army value by adding unit health+shield 788 | def enemy_army_value(self, position, distance=10): 789 | value = 0 790 | 791 | for unit in self.remembered_enemy_units.ready.not_structure.filter(lambda unit: unit.type_id not in self.units_to_ignore).closer_than(distance, position): 792 | value += unit.health + unit.shield 793 | 794 | # Add extra army value for marine/marauder, to not under-estimate 795 | if unit.type_id in [MARINE, MARAUDER]: 796 | value += 20 797 | 798 | # Count nearby cannons 799 | for unit in self.remembered_enemy_units(PHOTONCANNON).ready.closer_than(10, position): 800 | value += unit.health # Skip shield, to not overestimate 801 | 802 | # Count nearby bunkers 803 | for unit in self.remembered_enemy_units(BUNKER).ready.closer_than(10, position): 804 | value += unit.health 805 | 806 | # Count nearby spine crawlers 807 | for unit in self.remembered_enemy_units(SPINECRAWLER).ready.closer_than(10, position): 808 | value += unit.health 809 | 810 | return value 811 | 812 | def get_game_center_random(self, offset_x=50, offset_y=50): 813 | x = self.game_info.map_center.x 814 | y = self.game_info.map_center.y 815 | 816 | rand = random.random() 817 | if rand < 0.2: 818 | x += offset_x 819 | elif rand < 0.4: 820 | x -= offset_x 821 | elif rand < 0.6: 822 | y += offset_y 823 | elif rand < 0.8: 824 | y -= offset_y 825 | 826 | return sc2.position.Point2((x,y)) 827 | 828 | def get_base_build_location(self, base, min_distance=10, max_distance=20): 829 | return base.position.towards(self.get_game_center_random(), random.randrange(min_distance, max_distance)) 830 | 831 | --------------------------------------------------------------------------------