Top

sc2.bot_ai module

import math
import random

import logging
from typing import List, Dict, Set, Tuple, Any, Optional, Union # mypy type checking

# imports for mypy and pycharm autocomplete
from .game_state import GameState
from .game_data import GameData

logger = logging.getLogger(__name__)

from .position import Point2, Point3
from .data import Race, ActionResult, Attribute, race_worker, race_townhalls, race_gas, Target, Result
from .unit import Unit
from .cache import property_cache_forever
from .game_data import AbilityData, Cost
from .ids.unit_typeid import UnitTypeId
from .ids.ability_id import AbilityId
from .ids.upgrade_id import UpgradeId
from .units import Units


class BotAI:
    """Base class for bots."""

    EXPANSION_GAP_THRESHOLD = 15

    def __init__(self):
        # Specific opponent bot ID used in sc2ai ladder games http://sc2ai.net/
        # The bot ID will stay the same each game so your bot can "adapt" to the opponent
        self.opponent_id: int = None

    @property
    def enemy_race(self) -> Race:
        self.enemy_id = 3 - self.player_id
        return Race(self._game_info.player_races[self.enemy_id])

    @property
    def time(self) -> Union[int, float]:
        """ Returns time in seconds, assumes the game is played on 'faster' """
        return self.state.game_loop / 22.4 # / (1/1.4) * (1/16)

    @property
    def game_info(self) -> "GameInfo":
        return self._game_info

    @property
    def start_location(self) -> Point2:
        return self._game_info.player_start_location

    @property
    def enemy_start_locations(self) -> List[Point2]:
        """Possible start locations for enemies."""
        return self._game_info.start_locations

    @property
    def known_enemy_units(self) -> Units:
        """List of known enemy units, including structures."""
        return self.state.units.enemy

    @property
    def known_enemy_structures(self) -> Units:
        """List of known enemy units, structures only."""
        return self.state.units.enemy.structure

    @property
    def main_base_ramp(self) -> "Ramp":
        """ Returns the Ramp instance of the closest main-ramp to start location. Look in game_info.py for more information """
        if hasattr(self, "cached_main_base_ramp"):
            return self.cached_main_base_ramp
        self.cached_main_base_ramp = min(
            {ramp for ramp in self.game_info.map_ramps if len(ramp.upper2_for_ramp_wall) == 2},
            key=(lambda r: self.start_location.distance_to(r.top_center))
        )
        return self.cached_main_base_ramp

    @property_cache_forever
    def expansion_locations(self) -> Dict[Point2, Units]:
        """List of possible expansion locations."""

        RESOURCE_SPREAD_THRESHOLD = 144
        all_resources = self.state.mineral_field | self.state.vespene_geyser

        # Group nearby minerals together to form expansion locations
        r_groups = []
        for mf in all_resources:
            mf_height = self.get_terrain_height(mf.position)
            for g in r_groups:
                if any(
                    mf_height == self.get_terrain_height(p.position)
                    and mf.position._distance_squared(p.position) < RESOURCE_SPREAD_THRESHOLD
                    for p in g
                ):
                    g.append(mf)
                    break
            else:  # not found
                r_groups.append([mf])
        # Filter out bases with only one mineral field
        r_groups = [g for g in r_groups if len(g) > 1]
        # distance offsets from a gas geysir
        offsets = [(x, y) for x in range(-9, 10) for y in range(-9, 10) if 75 >= x ** 2 + y ** 2 >= 49]
        centers = {}
        # for every resource group:
        for resources in r_groups:
            # possible expansion points
            # resources[-1] is a gas geysir which always has (x.5, y.5) coordinates, just like an expansion
            possible_points = (
                Point2((offset[0] + resources[-1].position.x, offset[1] + resources[-1].position.y))
                for offset in offsets
            )
            # filter out points that are too near
            possible_points = [
                point
                for point in possible_points
                if all(
                    point.distance_to(resource) >= (6 if resource in self.state.mineral_field else 7)
                    for resource in resources
                )
            ]
            # choose best fitting point
            result = min(possible_points, key=lambda p: sum(p.distance_to(resource) for resource in resources))
            centers[result] = resources
        """ Returns dict with center of resources as key, resources (mineral field, vespene geyser) as value """
        return centers

    async def get_available_abilities(self, units: Union[List[Unit], Units], ignore_resource_requirements=False) -> List[List[AbilityId]]:
        """ Returns available abilities of one or more units. """
        # right know only checks cooldown, energy cost, and whether the ability has been researched
        return await self._client.query_available_abilities(units, ignore_resource_requirements)

    async def expand_now(self, building: UnitTypeId=None, max_distance: Union[int, float]=10, location: Optional[Point2]=None):
        """Takes new expansion."""

        if not building:
            # self.race is never Race.Random
            start_townhall_type = {Race.Protoss: UnitTypeId.NEXUS, Race.Terran: UnitTypeId.COMMANDCENTER, Race.Zerg: UnitTypeId.HATCHERY}
            building = start_townhall_type[self.race]

        assert isinstance(building, UnitTypeId)

        if not location:
            location = await self.get_next_expansion()
        
        if location:
            await self.build(building, near=location, max_distance=max_distance, random_alternative=False, placement_step=1)

    async def get_next_expansion(self) -> Optional[Point2]:
        """Find next expansion location."""

        closest = None
        distance = math.inf
        for el in self.expansion_locations:
            def is_near_to_expansion(t):
                return t.position.distance_to(el) < self.EXPANSION_GAP_THRESHOLD

            if any(map(is_near_to_expansion, self.townhalls)):
                # already taken
                continue

            startp = self._game_info.player_start_location
            d = await self._client.query_pathing(startp, el)
            if d is None:
                continue

            if d < distance:
                distance = d
                closest = el

        return closest

    async def distribute_workers(self):
        """
        Distributes workers across all the bases taken.
        WARNING: This is quite slow when there are lots of workers or multiple bases.
        """

        # TODO:
        # OPTIMIZE: Assign idle workers smarter
        # OPTIMIZE: Never use same worker mutltiple times
        owned_expansions = self.owned_expansions
        worker_pool = []
        actions = []

        for idle_worker in self.workers.idle:
            mf = self.state.mineral_field.closest_to(idle_worker)
            actions.append(idle_worker.gather(mf))

        for location, townhall in owned_expansions.items():
            workers = self.workers.closer_than(20, location)
            actual = townhall.assigned_harvesters
            ideal = townhall.ideal_harvesters
            excess = actual - ideal
            if actual > ideal:
                worker_pool.extend(workers.random_group_of(min(excess, len(workers))))
                continue
        for g in self.geysers:
            workers = self.workers.closer_than(5, g)
            actual = g.assigned_harvesters
            ideal = g.ideal_harvesters
            excess = actual - ideal
            if actual > ideal:
                worker_pool.extend(workers.random_group_of(min(excess, len(workers))))
                continue

        for g in self.geysers:
            actual = g.assigned_harvesters
            ideal = g.ideal_harvesters
            deficit = ideal - actual

            for _ in range(deficit):
                if worker_pool:
                    w = worker_pool.pop()
                    if len(w.orders) == 1 and w.orders[0].ability.id is AbilityId.HARVEST_RETURN:
                        actions.append(w.move(g))
                        actions.append(w.return_resource(queue=True))
                    else:
                        actions.append(w.gather(g))

        for location, townhall in owned_expansions.items():
            actual = townhall.assigned_harvesters
            ideal = townhall.ideal_harvesters

            deficit = ideal - actual
            for x in range(0, deficit):
                if worker_pool:
                    w = worker_pool.pop()
                    mf = self.state.mineral_field.closest_to(townhall)
                    if len(w.orders) == 1 and w.orders[0].ability.id is AbilityId.HARVEST_RETURN:
                        actions.append(w.move(townhall))
                        actions.append(w.return_resource(queue=True))
                        actions.append(w.gather(mf, queue=True))
                    else:
                        actions.append(w.gather(mf))

        await self.do_actions(actions)

    @property
    def owned_expansions(self):
        """List of expansions owned by the player."""

        owned = {}
        for el in self.expansion_locations:
            def is_near_to_expansion(t):
                return t.position.distance_to(el) < self.EXPANSION_GAP_THRESHOLD

            th = next((x for x in self.townhalls if is_near_to_expansion(x)), None)
            if th:
                owned[el] = th

        return owned

    def can_feed(self, unit_type: UnitTypeId) -> bool:
        """ Checks if you have enough free supply to build the unit """
        required = self._game_data.units[unit_type.value]._proto.food_required
        return required == 0 or self.supply_left >= required

    def can_afford(self, item_id: Union[UnitTypeId, UpgradeId, AbilityId], check_supply_cost: bool=True) -> "CanAffordWrapper":
        """Tests if the player has enough resources to build a unit or cast an ability."""
        enough_supply = True
        if isinstance(item_id, UnitTypeId):
            unit = self._game_data.units[item_id.value]
            cost = self._game_data.calculate_ability_cost(unit.creation_ability)
            if check_supply_cost:
                enough_supply = self.can_feed(item_id)
        elif isinstance(item_id, UpgradeId):
            cost = self._game_data.upgrades[item_id.value].cost
        else:
            cost = self._game_data.calculate_ability_cost(item_id)

        return CanAffordWrapper(cost.minerals <= self.minerals, cost.vespene <= self.vespene, enough_supply)

    async def can_cast(self, unit: Unit, ability_id: AbilityId, target: Optional[Union[Unit, Point2, Point3]]=None, only_check_energy_and_cooldown: bool=False, cached_abilities_of_unit: List[AbilityId]=None) -> bool:
        """Tests if a unit has an ability available and enough energy to cast it.
        See data_pb2.py (line 161) for the numbers 1-5 to make sense"""
        assert isinstance(unit, Unit)
        assert isinstance(ability_id, AbilityId)
        assert isinstance(target, (type(None), Unit, Point2, Point3))
        # check if unit has enough energy to cast or if ability is on cooldown
        if cached_abilities_of_unit:
            abilities = cached_abilities_of_unit
        else:
            abilities = (await self.get_available_abilities([unit]))[0]

        if ability_id in abilities:
            if only_check_energy_and_cooldown:
                return True
            cast_range = self._game_data.abilities[ability_id.value]._proto.cast_range
            ability_target = self._game_data.abilities[ability_id.value]._proto.target
            # Check if target is in range (or is a self cast like stimpack)
            if ability_target == 1 or ability_target == Target.PointOrNone.value and isinstance(target, (Point2, Point3)) and unit.distance_to(target) <= cast_range: # cant replace 1 with "Target.None.value" because ".None" doesnt seem to be a valid enum name
                return True
            # Check if able to use ability on a unit
            elif ability_target in {Target.Unit.value, Target.PointOrUnit.value} and isinstance(target, Unit) and unit.distance_to(target) <= cast_range:
                return True
            # Check if able to use ability on a position
            elif ability_target in {Target.Point.value, Target.PointOrUnit.value} and isinstance(target, (Point2, Point3)) and unit.distance_to(target) <= cast_range:
                return True
        return False

    def select_build_worker(self, pos: Union[Unit, Point2, Point3], force: bool=False) -> Optional[Unit]:
        """Select a worker to build a bulding with."""

        workers = self.workers.closer_than(20, pos) or self.workers
        for worker in workers.prefer_close_to(pos).prefer_idle:
            if not worker.orders or len(worker.orders) == 1 and worker.orders[0].ability.id in {AbilityId.MOVE,
                                                                                                AbilityId.HARVEST_GATHER,
                                                                                                AbilityId.HARVEST_RETURN}:
                return worker

        return workers.random if force else None

    async def can_place(self, building: Union[AbilityData, AbilityId, UnitTypeId], position: Point2) -> bool:
        """Tests if a building can be placed in the given location."""

        assert isinstance(building, (AbilityData, AbilityId, UnitTypeId))

        if isinstance(building, UnitTypeId):
            building = self._game_data.units[building.value].creation_ability
        elif isinstance(building, AbilityId):
            building = self._game_data.abilities[building.value]

        r = await self._client.query_building_placement(building, [position])
        return r[0] == ActionResult.Success

    async def find_placement(self, building: UnitTypeId, near: Union[Unit, Point2, Point3], max_distance: int=20, random_alternative: bool=True, placement_step: int=2) -> Optional[Point2]:
        """Finds a placement location for building."""

        assert isinstance(building, (AbilityId, UnitTypeId))
        assert isinstance(near, Point2)

        if isinstance(building, UnitTypeId):
            building = self._game_data.units[building.value].creation_ability
        else:  # AbilityId
            building = self._game_data.abilities[building.value]

        if await self.can_place(building, near):
            return near

        if max_distance == 0:
            return None

        for distance in range(placement_step, max_distance, placement_step):
            possible_positions = [Point2(p).offset(near).to2 for p in (
                    [(dx, -distance) for dx in range(-distance, distance + 1, placement_step)] +
                    [(dx, distance) for dx in range(-distance, distance + 1, placement_step)] +
                    [(-distance, dy) for dy in range(-distance, distance + 1, placement_step)] +
                    [(distance, dy) for dy in range(-distance, distance + 1, placement_step)]
            )]
            res = await self._client.query_building_placement(building, possible_positions)
            possible = [p for r, p in zip(res, possible_positions) if r == ActionResult.Success]
            if not possible:
                continue

            if random_alternative:
                return random.choice(possible)
            else:
                return min(possible, key=lambda p: p.distance_to(near))
        return None

    def already_pending_upgrade(self, upgrade_type: UpgradeId) -> Union[int, float]:
        """ Check if an upgrade is being researched
        Return values:
        0: not started
        0 < x < 1: researching
        1: finished
        """
        assert isinstance(upgrade_type, UpgradeId)
        if upgrade_type in self.state.upgrades:
            return 1
        level = None
        if "LEVEL" in upgrade_type.name:
            level = upgrade_type.name[-1]
        creationAbilityID = self._game_data.upgrades[upgrade_type.value].research_ability.id
        for structure in self.units.structure.ready:
            for order in structure.orders:
                if order.ability.id is creationAbilityID:
                    if level and order.ability.button_name[-1] != level:
                        return 0
                    return order.progress
        return 0

    def already_pending(self, unit_type: Union[UpgradeId, UnitTypeId], all_units: bool=False) -> int:
        """
        Returns a number of buildings or units already in progress, or if a
        worker is en route to build it. This also includes queued orders for
        workers and build queues of buildings.

        If all_units==True, then build queues of other units (such as Carriers
        (Interceptors) or Oracles (Stasis Ward)) are also included.
        """

        # TODO / FIXME: SCV building a structure might be counted as two units

        if isinstance(unit_type, UpgradeId):
            return self.already_pending_upgrade(unit_type)
            
        ability = self._game_data.units[unit_type.value].creation_ability

        amount = len(self.units(unit_type).not_ready)

        if all_units:
            amount += sum([o.ability == ability for u in self.units for o in u.orders])
        else:
            amount += sum([o.ability == ability for w in self.workers for o in w.orders])
            amount += sum([egg.orders[0].ability == ability for egg in self.units(UnitTypeId.EGG)])

        return amount

    async def build(self, building: UnitTypeId, near: Union[Point2, Point3], max_distance: int=20, unit: Optional[Unit]=None, random_alternative: bool=True, placement_step: int=2):
        """Build a building."""

        if isinstance(near, Unit):
            near = near.position.to2
        elif near is not None:
            near = near.to2
        else:
            return

        p = await self.find_placement(building, near.rounded, max_distance, random_alternative, placement_step)
        if p is None:
            return ActionResult.CantFindPlacementLocation

        unit = unit or self.select_build_worker(p)
        if unit is None or not self.can_afford(building):
            return ActionResult.Error
        return await self.do(unit.build(building, p))

    async def do(self, action):
        if not self.can_afford(action):
            logger.warning(f"Cannot afford action {action}")
            return ActionResult.Error

        r = await self._client.actions(action, game_data=self._game_data)

        if not r:  # success
            cost = self._game_data.calculate_ability_cost(action.ability)
            self.minerals -= cost.minerals
            self.vespene -= cost.vespene

        else:
            logger.error(f"Error: {r} (action: {action})")

        return r

    async def do_actions(self, actions: List["UnitCommand"]):
        if not actions:
            return None
        for action in actions:
            cost = self._game_data.calculate_ability_cost(action.ability)
            self.minerals -= cost.minerals
            self.vespene -= cost.vespene

        r = await self._client.actions(actions, game_data=self._game_data)
        return r

    async def chat_send(self, message: str):
        """Send a chat message."""
        assert isinstance(message, str)
        await self._client.chat_send(message, False)

    # For the functions below, make sure you are inside the boundries of the map size.
    def get_terrain_height(self, pos: Union[Point2, Point3, Unit]) -> int:
        """ Returns terrain height at a position. Caution: terrain height is not anywhere near a unit's z-coordinate. """
        assert isinstance(pos, (Point2, Point3, Unit))
        pos = pos.position.to2.rounded
        return self._game_info.terrain_height[pos] # returns int

    def in_placement_grid(self, pos: Union[Point2, Point3, Unit]) -> bool:
        """ Returns True if you can place something at a position. Remember, buildings usually use 2x2, 3x3 or 5x5 of these grid points.
        Caution: some x and y offset might be required, see ramp code:
        https://github.com/Dentosal/python-sc2/blob/master/sc2/game_info.py#L17-L18 """
        assert isinstance(pos, (Point2, Point3, Unit))
        pos = pos.position.to2.rounded
        return self._game_info.placement_grid[pos] != 0

    def in_pathing_grid(self, pos: Union[Point2, Point3, Unit]) -> bool:
        """ Returns True if a unit can pass through a grid point. """
        assert isinstance(pos, (Point2, Point3, Unit))
        pos = pos.position.to2.rounded
        return self._game_info.pathing_grid[pos] == 0

    def is_visible(self, pos: Union[Point2, Point3, Unit]) -> bool:
        """ Returns True if you have vision on a grid point. """
        # more info: https://github.com/Blizzard/s2client-proto/blob/9906df71d6909511907d8419b33acc1a3bd51ec0/s2clientprotocol/spatial.proto#L19
        assert isinstance(pos, (Point2, Point3, Unit))
        pos = pos.position.to2.rounded
        return self.state.visibility[pos] == 2

    def has_creep(self, pos: Union[Point2, Point3, Unit]) -> bool:
        """ Returns True if there is creep on the grid point. """
        assert isinstance(pos, (Point2, Point3, Unit))
        pos = pos.position.to2.rounded
        return self.state.creep[pos] != 0

    def _prepare_start(self, client, player_id, game_info, game_data):
        """Ran until game start to set game and player data."""
        self._client: "Client" = client
        self._game_info: "GameInfo" = game_info
        self._game_data: GameData = game_data

        self.player_id: int = player_id
        self.race: Race = Race(self._game_info.player_races[self.player_id])
        self._units_previous_map: dict = dict()
        self.units: Units = Units([], game_data)

    def _prepare_first_step(self):
        """First step extra preparations. Must not be called before _prepare_step."""
        if self.townhalls:
            self._game_info.player_start_location = self.townhalls.first.position
        self._game_info.map_ramps = self._game_info._find_ramps()

    def _prepare_step(self, state):
        """Set attributes from new state before on_step."""
        self.state: GameState = state
        # Required for events
        self._units_previous_map.clear()
        for unit in self.units:
            self._units_previous_map[unit.tag] = unit

        self.units: Units = state.units.owned
        self.workers: Units = self.units(race_worker[self.race])
        self.townhalls: Units = self.units(race_townhalls[self.race])
        self.geysers: Units = self.units(race_gas[self.race])

        self.minerals: Union[float, int] = state.common.minerals
        self.vespene: Union[float, int] = state.common.vespene
        self.supply_used: Union[float, int] = state.common.food_used
        self.supply_cap: Union[float, int] = state.common.food_cap
        self.supply_left: Union[float, int] = self.supply_cap - self.supply_used

    async def issue_events(self):
        """ This function will be automatically run from main.py and triggers the following functions:
        - on_unit_created
        - on_unit_destroyed
        - on_building_construction_complete
        """
        await self._issue_unit_dead_events()
        await self._issue_unit_added_events()
        for unit in self.units.structure:
            await self._issue_building_complete_event(unit)

    async def _issue_unit_added_events(self):
        for unit in self.units.not_structure:
            if unit.tag not in self._units_previous_map:
                await self.on_unit_created(unit)

    async def _issue_building_complete_event(self, unit):
        if unit.build_progress < 1:
            return
        if unit.tag not in self._units_previous_map:
            return
        unit_prev = self._units_previous_map[unit.tag]
        if unit_prev.build_progress < 1:
            await self.on_building_construction_complete(unit)

    async def _issue_unit_dead_events(self):
        event = self.state.observation.raw_data.event
        if event is not None:
            for tag in event.dead_units:
                await self.on_unit_destroyed(tag)

    async def on_unit_destroyed(self, unit_tag):
        """ Override this in your bot class. """
        pass

    async def on_unit_created(self, unit: Unit):
        """ Override this in your bot class. """
        pass

    async def on_building_construction_complete(self, unit: Unit):
        """ Override this in your bot class. """
        pass

    def on_start(self):
        """Allows initializing the bot when the game data is available."""
        pass

    async def on_step(self, iteration: int):
        """Ran on every game step (looped in realtime mode)."""
        raise NotImplementedError

    def on_end(self, game_result: Result):
        """Ran at the end of a game."""
        pass


class CanAffordWrapper:
    def __init__(self, can_afford_minerals, can_afford_vespene, have_enough_supply):
        self.can_afford_minerals = can_afford_minerals
        self.can_afford_vespene = can_afford_vespene
        self.have_enough_supply = have_enough_supply

    def __bool__(self):
        return self.can_afford_minerals and self.can_afford_vespene and self.have_enough_supply

    @property
    def action_result(self):
        if not self.can_afford_vespene:
            return ActionResult.NotEnoughVespene
        elif not self.can_afford_minerals:
            return ActionResult.NotEnoughMinerals
        elif not self.have_enough_supply:
            return ActionResult.NotEnoughFood
        else:
            return None

Module variables

var logger

var race_gas

var race_townhalls

var race_worker

Classes

class BotAI

Base class for bots.

class BotAI:
    """Base class for bots."""

    EXPANSION_GAP_THRESHOLD = 15

    def __init__(self):
        # Specific opponent bot ID used in sc2ai ladder games http://sc2ai.net/
        # The bot ID will stay the same each game so your bot can "adapt" to the opponent
        self.opponent_id: int = None

    @property
    def enemy_race(self) -> Race:
        self.enemy_id = 3 - self.player_id
        return Race(self._game_info.player_races[self.enemy_id])

    @property
    def time(self) -> Union[int, float]:
        """ Returns time in seconds, assumes the game is played on 'faster' """
        return self.state.game_loop / 22.4 # / (1/1.4) * (1/16)

    @property
    def game_info(self) -> "GameInfo":
        return self._game_info

    @property
    def start_location(self) -> Point2:
        return self._game_info.player_start_location

    @property
    def enemy_start_locations(self) -> List[Point2]:
        """Possible start locations for enemies."""
        return self._game_info.start_locations

    @property
    def known_enemy_units(self) -> Units:
        """List of known enemy units, including structures."""
        return self.state.units.enemy

    @property
    def known_enemy_structures(self) -> Units:
        """List of known enemy units, structures only."""
        return self.state.units.enemy.structure

    @property
    def main_base_ramp(self) -> "Ramp":
        """ Returns the Ramp instance of the closest main-ramp to start location. Look in game_info.py for more information """
        if hasattr(self, "cached_main_base_ramp"):
            return self.cached_main_base_ramp
        self.cached_main_base_ramp = min(
            {ramp for ramp in self.game_info.map_ramps if len(ramp.upper2_for_ramp_wall) == 2},
            key=(lambda r: self.start_location.distance_to(r.top_center))
        )
        return self.cached_main_base_ramp

    @property_cache_forever
    def expansion_locations(self) -> Dict[Point2, Units]:
        """List of possible expansion locations."""

        RESOURCE_SPREAD_THRESHOLD = 144
        all_resources = self.state.mineral_field | self.state.vespene_geyser

        # Group nearby minerals together to form expansion locations
        r_groups = []
        for mf in all_resources:
            mf_height = self.get_terrain_height(mf.position)
            for g in r_groups:
                if any(
                    mf_height == self.get_terrain_height(p.position)
                    and mf.position._distance_squared(p.position) < RESOURCE_SPREAD_THRESHOLD
                    for p in g
                ):
                    g.append(mf)
                    break
            else:  # not found
                r_groups.append([mf])
        # Filter out bases with only one mineral field
        r_groups = [g for g in r_groups if len(g) > 1]
        # distance offsets from a gas geysir
        offsets = [(x, y) for x in range(-9, 10) for y in range(-9, 10) if 75 >= x ** 2 + y ** 2 >= 49]
        centers = {}
        # for every resource group:
        for resources in r_groups:
            # possible expansion points
            # resources[-1] is a gas geysir which always has (x.5, y.5) coordinates, just like an expansion
            possible_points = (
                Point2((offset[0] + resources[-1].position.x, offset[1] + resources[-1].position.y))
                for offset in offsets
            )
            # filter out points that are too near
            possible_points = [
                point
                for point in possible_points
                if all(
                    point.distance_to(resource) >= (6 if resource in self.state.mineral_field else 7)
                    for resource in resources
                )
            ]
            # choose best fitting point
            result = min(possible_points, key=lambda p: sum(p.distance_to(resource) for resource in resources))
            centers[result] = resources
        """ Returns dict with center of resources as key, resources (mineral field, vespene geyser) as value """
        return centers

    async def get_available_abilities(self, units: Union[List[Unit], Units], ignore_resource_requirements=False) -> List[List[AbilityId]]:
        """ Returns available abilities of one or more units. """
        # right know only checks cooldown, energy cost, and whether the ability has been researched
        return await self._client.query_available_abilities(units, ignore_resource_requirements)

    async def expand_now(self, building: UnitTypeId=None, max_distance: Union[int, float]=10, location: Optional[Point2]=None):
        """Takes new expansion."""

        if not building:
            # self.race is never Race.Random
            start_townhall_type = {Race.Protoss: UnitTypeId.NEXUS, Race.Terran: UnitTypeId.COMMANDCENTER, Race.Zerg: UnitTypeId.HATCHERY}
            building = start_townhall_type[self.race]

        assert isinstance(building, UnitTypeId)

        if not location:
            location = await self.get_next_expansion()
        
        if location:
            await self.build(building, near=location, max_distance=max_distance, random_alternative=False, placement_step=1)

    async def get_next_expansion(self) -> Optional[Point2]:
        """Find next expansion location."""

        closest = None
        distance = math.inf
        for el in self.expansion_locations:
            def is_near_to_expansion(t):
                return t.position.distance_to(el) < self.EXPANSION_GAP_THRESHOLD

            if any(map(is_near_to_expansion, self.townhalls)):
                # already taken
                continue

            startp = self._game_info.player_start_location
            d = await self._client.query_pathing(startp, el)
            if d is None:
                continue

            if d < distance:
                distance = d
                closest = el

        return closest

    async def distribute_workers(self):
        """
        Distributes workers across all the bases taken.
        WARNING: This is quite slow when there are lots of workers or multiple bases.
        """

        # TODO:
        # OPTIMIZE: Assign idle workers smarter
        # OPTIMIZE: Never use same worker mutltiple times
        owned_expansions = self.owned_expansions
        worker_pool = []
        actions = []

        for idle_worker in self.workers.idle:
            mf = self.state.mineral_field.closest_to(idle_worker)
            actions.append(idle_worker.gather(mf))

        for location, townhall in owned_expansions.items():
            workers = self.workers.closer_than(20, location)
            actual = townhall.assigned_harvesters
            ideal = townhall.ideal_harvesters
            excess = actual - ideal
            if actual > ideal:
                worker_pool.extend(workers.random_group_of(min(excess, len(workers))))
                continue
        for g in self.geysers:
            workers = self.workers.closer_than(5, g)
            actual = g.assigned_harvesters
            ideal = g.ideal_harvesters
            excess = actual - ideal
            if actual > ideal:
                worker_pool.extend(workers.random_group_of(min(excess, len(workers))))
                continue

        for g in self.geysers:
            actual = g.assigned_harvesters
            ideal = g.ideal_harvesters
            deficit = ideal - actual

            for _ in range(deficit):
                if worker_pool:
                    w = worker_pool.pop()
                    if len(w.orders) == 1 and w.orders[0].ability.id is AbilityId.HARVEST_RETURN:
                        actions.append(w.move(g))
                        actions.append(w.return_resource(queue=True))
                    else:
                        actions.append(w.gather(g))

        for location, townhall in owned_expansions.items():
            actual = townhall.assigned_harvesters
            ideal = townhall.ideal_harvesters

            deficit = ideal - actual
            for x in range(0, deficit):
                if worker_pool:
                    w = worker_pool.pop()
                    mf = self.state.mineral_field.closest_to(townhall)
                    if len(w.orders) == 1 and w.orders[0].ability.id is AbilityId.HARVEST_RETURN:
                        actions.append(w.move(townhall))
                        actions.append(w.return_resource(queue=True))
                        actions.append(w.gather(mf, queue=True))
                    else:
                        actions.append(w.gather(mf))

        await self.do_actions(actions)

    @property
    def owned_expansions(self):
        """List of expansions owned by the player."""

        owned = {}
        for el in self.expansion_locations:
            def is_near_to_expansion(t):
                return t.position.distance_to(el) < self.EXPANSION_GAP_THRESHOLD

            th = next((x for x in self.townhalls if is_near_to_expansion(x)), None)
            if th:
                owned[el] = th

        return owned

    def can_feed(self, unit_type: UnitTypeId) -> bool:
        """ Checks if you have enough free supply to build the unit """
        required = self._game_data.units[unit_type.value]._proto.food_required
        return required == 0 or self.supply_left >= required

    def can_afford(self, item_id: Union[UnitTypeId, UpgradeId, AbilityId], check_supply_cost: bool=True) -> "CanAffordWrapper":
        """Tests if the player has enough resources to build a unit or cast an ability."""
        enough_supply = True
        if isinstance(item_id, UnitTypeId):
            unit = self._game_data.units[item_id.value]
            cost = self._game_data.calculate_ability_cost(unit.creation_ability)
            if check_supply_cost:
                enough_supply = self.can_feed(item_id)
        elif isinstance(item_id, UpgradeId):
            cost = self._game_data.upgrades[item_id.value].cost
        else:
            cost = self._game_data.calculate_ability_cost(item_id)

        return CanAffordWrapper(cost.minerals <= self.minerals, cost.vespene <= self.vespene, enough_supply)

    async def can_cast(self, unit: Unit, ability_id: AbilityId, target: Optional[Union[Unit, Point2, Point3]]=None, only_check_energy_and_cooldown: bool=False, cached_abilities_of_unit: List[AbilityId]=None) -> bool:
        """Tests if a unit has an ability available and enough energy to cast it.
        See data_pb2.py (line 161) for the numbers 1-5 to make sense"""
        assert isinstance(unit, Unit)
        assert isinstance(ability_id, AbilityId)
        assert isinstance(target, (type(None), Unit, Point2, Point3))
        # check if unit has enough energy to cast or if ability is on cooldown
        if cached_abilities_of_unit:
            abilities = cached_abilities_of_unit
        else:
            abilities = (await self.get_available_abilities([unit]))[0]

        if ability_id in abilities:
            if only_check_energy_and_cooldown:
                return True
            cast_range = self._game_data.abilities[ability_id.value]._proto.cast_range
            ability_target = self._game_data.abilities[ability_id.value]._proto.target
            # Check if target is in range (or is a self cast like stimpack)
            if ability_target == 1 or ability_target == Target.PointOrNone.value and isinstance(target, (Point2, Point3)) and unit.distance_to(target) <= cast_range: # cant replace 1 with "Target.None.value" because ".None" doesnt seem to be a valid enum name
                return True
            # Check if able to use ability on a unit
            elif ability_target in {Target.Unit.value, Target.PointOrUnit.value} and isinstance(target, Unit) and unit.distance_to(target) <= cast_range:
                return True
            # Check if able to use ability on a position
            elif ability_target in {Target.Point.value, Target.PointOrUnit.value} and isinstance(target, (Point2, Point3)) and unit.distance_to(target) <= cast_range:
                return True
        return False

    def select_build_worker(self, pos: Union[Unit, Point2, Point3], force: bool=False) -> Optional[Unit]:
        """Select a worker to build a bulding with."""

        workers = self.workers.closer_than(20, pos) or self.workers
        for worker in workers.prefer_close_to(pos).prefer_idle:
            if not worker.orders or len(worker.orders) == 1 and worker.orders[0].ability.id in {AbilityId.MOVE,
                                                                                                AbilityId.HARVEST_GATHER,
                                                                                                AbilityId.HARVEST_RETURN}:
                return worker

        return workers.random if force else None

    async def can_place(self, building: Union[AbilityData, AbilityId, UnitTypeId], position: Point2) -> bool:
        """Tests if a building can be placed in the given location."""

        assert isinstance(building, (AbilityData, AbilityId, UnitTypeId))

        if isinstance(building, UnitTypeId):
            building = self._game_data.units[building.value].creation_ability
        elif isinstance(building, AbilityId):
            building = self._game_data.abilities[building.value]

        r = await self._client.query_building_placement(building, [position])
        return r[0] == ActionResult.Success

    async def find_placement(self, building: UnitTypeId, near: Union[Unit, Point2, Point3], max_distance: int=20, random_alternative: bool=True, placement_step: int=2) -> Optional[Point2]:
        """Finds a placement location for building."""

        assert isinstance(building, (AbilityId, UnitTypeId))
        assert isinstance(near, Point2)

        if isinstance(building, UnitTypeId):
            building = self._game_data.units[building.value].creation_ability
        else:  # AbilityId
            building = self._game_data.abilities[building.value]

        if await self.can_place(building, near):
            return near

        if max_distance == 0:
            return None

        for distance in range(placement_step, max_distance, placement_step):
            possible_positions = [Point2(p).offset(near).to2 for p in (
                    [(dx, -distance) for dx in range(-distance, distance + 1, placement_step)] +
                    [(dx, distance) for dx in range(-distance, distance + 1, placement_step)] +
                    [(-distance, dy) for dy in range(-distance, distance + 1, placement_step)] +
                    [(distance, dy) for dy in range(-distance, distance + 1, placement_step)]
            )]
            res = await self._client.query_building_placement(building, possible_positions)
            possible = [p for r, p in zip(res, possible_positions) if r == ActionResult.Success]
            if not possible:
                continue

            if random_alternative:
                return random.choice(possible)
            else:
                return min(possible, key=lambda p: p.distance_to(near))
        return None

    def already_pending_upgrade(self, upgrade_type: UpgradeId) -> Union[int, float]:
        """ Check if an upgrade is being researched
        Return values:
        0: not started
        0 < x < 1: researching
        1: finished
        """
        assert isinstance(upgrade_type, UpgradeId)
        if upgrade_type in self.state.upgrades:
            return 1
        level = None
        if "LEVEL" in upgrade_type.name:
            level = upgrade_type.name[-1]
        creationAbilityID = self._game_data.upgrades[upgrade_type.value].research_ability.id
        for structure in self.units.structure.ready:
            for order in structure.orders:
                if order.ability.id is creationAbilityID:
                    if level and order.ability.button_name[-1] != level:
                        return 0
                    return order.progress
        return 0

    def already_pending(self, unit_type: Union[UpgradeId, UnitTypeId], all_units: bool=False) -> int:
        """
        Returns a number of buildings or units already in progress, or if a
        worker is en route to build it. This also includes queued orders for
        workers and build queues of buildings.

        If all_units==True, then build queues of other units (such as Carriers
        (Interceptors) or Oracles (Stasis Ward)) are also included.
        """

        # TODO / FIXME: SCV building a structure might be counted as two units

        if isinstance(unit_type, UpgradeId):
            return self.already_pending_upgrade(unit_type)
            
        ability = self._game_data.units[unit_type.value].creation_ability

        amount = len(self.units(unit_type).not_ready)

        if all_units:
            amount += sum([o.ability == ability for u in self.units for o in u.orders])
        else:
            amount += sum([o.ability == ability for w in self.workers for o in w.orders])
            amount += sum([egg.orders[0].ability == ability for egg in self.units(UnitTypeId.EGG)])

        return amount

    async def build(self, building: UnitTypeId, near: Union[Point2, Point3], max_distance: int=20, unit: Optional[Unit]=None, random_alternative: bool=True, placement_step: int=2):
        """Build a building."""

        if isinstance(near, Unit):
            near = near.position.to2
        elif near is not None:
            near = near.to2
        else:
            return

        p = await self.find_placement(building, near.rounded, max_distance, random_alternative, placement_step)
        if p is None:
            return ActionResult.CantFindPlacementLocation

        unit = unit or self.select_build_worker(p)
        if unit is None or not self.can_afford(building):
            return ActionResult.Error
        return await self.do(unit.build(building, p))

    async def do(self, action):
        if not self.can_afford(action):
            logger.warning(f"Cannot afford action {action}")
            return ActionResult.Error

        r = await self._client.actions(action, game_data=self._game_data)

        if not r:  # success
            cost = self._game_data.calculate_ability_cost(action.ability)
            self.minerals -= cost.minerals
            self.vespene -= cost.vespene

        else:
            logger.error(f"Error: {r} (action: {action})")

        return r

    async def do_actions(self, actions: List["UnitCommand"]):
        if not actions:
            return None
        for action in actions:
            cost = self._game_data.calculate_ability_cost(action.ability)
            self.minerals -= cost.minerals
            self.vespene -= cost.vespene

        r = await self._client.actions(actions, game_data=self._game_data)
        return r

    async def chat_send(self, message: str):
        """Send a chat message."""
        assert isinstance(message, str)
        await self._client.chat_send(message, False)

    # For the functions below, make sure you are inside the boundries of the map size.
    def get_terrain_height(self, pos: Union[Point2, Point3, Unit]) -> int:
        """ Returns terrain height at a position. Caution: terrain height is not anywhere near a unit's z-coordinate. """
        assert isinstance(pos, (Point2, Point3, Unit))
        pos = pos.position.to2.rounded
        return self._game_info.terrain_height[pos] # returns int

    def in_placement_grid(self, pos: Union[Point2, Point3, Unit]) -> bool:
        """ Returns True if you can place something at a position. Remember, buildings usually use 2x2, 3x3 or 5x5 of these grid points.
        Caution: some x and y offset might be required, see ramp code:
        https://github.com/Dentosal/python-sc2/blob/master/sc2/game_info.py#L17-L18 """
        assert isinstance(pos, (Point2, Point3, Unit))
        pos = pos.position.to2.rounded
        return self._game_info.placement_grid[pos] != 0

    def in_pathing_grid(self, pos: Union[Point2, Point3, Unit]) -> bool:
        """ Returns True if a unit can pass through a grid point. """
        assert isinstance(pos, (Point2, Point3, Unit))
        pos = pos.position.to2.rounded
        return self._game_info.pathing_grid[pos] == 0

    def is_visible(self, pos: Union[Point2, Point3, Unit]) -> bool:
        """ Returns True if you have vision on a grid point. """
        # more info: https://github.com/Blizzard/s2client-proto/blob/9906df71d6909511907d8419b33acc1a3bd51ec0/s2clientprotocol/spatial.proto#L19
        assert isinstance(pos, (Point2, Point3, Unit))
        pos = pos.position.to2.rounded
        return self.state.visibility[pos] == 2

    def has_creep(self, pos: Union[Point2, Point3, Unit]) -> bool:
        """ Returns True if there is creep on the grid point. """
        assert isinstance(pos, (Point2, Point3, Unit))
        pos = pos.position.to2.rounded
        return self.state.creep[pos] != 0

    def _prepare_start(self, client, player_id, game_info, game_data):
        """Ran until game start to set game and player data."""
        self._client: "Client" = client
        self._game_info: "GameInfo" = game_info
        self._game_data: GameData = game_data

        self.player_id: int = player_id
        self.race: Race = Race(self._game_info.player_races[self.player_id])
        self._units_previous_map: dict = dict()
        self.units: Units = Units([], game_data)

    def _prepare_first_step(self):
        """First step extra preparations. Must not be called before _prepare_step."""
        if self.townhalls:
            self._game_info.player_start_location = self.townhalls.first.position
        self._game_info.map_ramps = self._game_info._find_ramps()

    def _prepare_step(self, state):
        """Set attributes from new state before on_step."""
        self.state: GameState = state
        # Required for events
        self._units_previous_map.clear()
        for unit in self.units:
            self._units_previous_map[unit.tag] = unit

        self.units: Units = state.units.owned
        self.workers: Units = self.units(race_worker[self.race])
        self.townhalls: Units = self.units(race_townhalls[self.race])
        self.geysers: Units = self.units(race_gas[self.race])

        self.minerals: Union[float, int] = state.common.minerals
        self.vespene: Union[float, int] = state.common.vespene
        self.supply_used: Union[float, int] = state.common.food_used
        self.supply_cap: Union[float, int] = state.common.food_cap
        self.supply_left: Union[float, int] = self.supply_cap - self.supply_used

    async def issue_events(self):
        """ This function will be automatically run from main.py and triggers the following functions:
        - on_unit_created
        - on_unit_destroyed
        - on_building_construction_complete
        """
        await self._issue_unit_dead_events()
        await self._issue_unit_added_events()
        for unit in self.units.structure:
            await self._issue_building_complete_event(unit)

    async def _issue_unit_added_events(self):
        for unit in self.units.not_structure:
            if unit.tag not in self._units_previous_map:
                await self.on_unit_created(unit)

    async def _issue_building_complete_event(self, unit):
        if unit.build_progress < 1:
            return
        if unit.tag not in self._units_previous_map:
            return
        unit_prev = self._units_previous_map[unit.tag]
        if unit_prev.build_progress < 1:
            await self.on_building_construction_complete(unit)

    async def _issue_unit_dead_events(self):
        event = self.state.observation.raw_data.event
        if event is not None:
            for tag in event.dead_units:
                await self.on_unit_destroyed(tag)

    async def on_unit_destroyed(self, unit_tag):
        """ Override this in your bot class. """
        pass

    async def on_unit_created(self, unit: Unit):
        """ Override this in your bot class. """
        pass

    async def on_building_construction_complete(self, unit: Unit):
        """ Override this in your bot class. """
        pass

    def on_start(self):
        """Allows initializing the bot when the game data is available."""
        pass

    async def on_step(self, iteration: int):
        """Ran on every game step (looped in realtime mode)."""
        raise NotImplementedError

    def on_end(self, game_result: Result):
        """Ran at the end of a game."""
        pass

Ancestors (in MRO)

Class variables

var EXPANSION_GAP_THRESHOLD

Static methods

def __init__(

self)

Initialize self. See help(type(self)) for accurate signature.

def __init__(self):
    # Specific opponent bot ID used in sc2ai ladder games http://sc2ai.net/
    # The bot ID will stay the same each game so your bot can "adapt" to the opponent
    self.opponent_id: int = None

def already_pending(

self, unit_type, all_units=False)

Returns a number of buildings or units already in progress, or if a worker is en route to build it. This also includes queued orders for workers and build queues of buildings.

If all_units==True, then build queues of other units (such as Carriers (Interceptors) or Oracles (Stasis Ward)) are also included.

def already_pending(self, unit_type: Union[UpgradeId, UnitTypeId], all_units: bool=False) -> int:
    """
    Returns a number of buildings or units already in progress, or if a
    worker is en route to build it. This also includes queued orders for
    workers and build queues of buildings.
    If all_units==True, then build queues of other units (such as Carriers
    (Interceptors) or Oracles (Stasis Ward)) are also included.
    """
    # TODO / FIXME: SCV building a structure might be counted as two units
    if isinstance(unit_type, UpgradeId):
        return self.already_pending_upgrade(unit_type)
        
    ability = self._game_data.units[unit_type.value].creation_ability
    amount = len(self.units(unit_type).not_ready)
    if all_units:
        amount += sum([o.ability == ability for u in self.units for o in u.orders])
    else:
        amount += sum([o.ability == ability for w in self.workers for o in w.orders])
        amount += sum([egg.orders[0].ability == ability for egg in self.units(UnitTypeId.EGG)])
    return amount

def already_pending_upgrade(

self, upgrade_type)

Check if an upgrade is being researched Return values: 0: not started 0 < x < 1: researching 1: finished

def already_pending_upgrade(self, upgrade_type: UpgradeId) -> Union[int, float]:
    """ Check if an upgrade is being researched
    Return values:
    0: not started
    0 < x < 1: researching
    1: finished
    """
    assert isinstance(upgrade_type, UpgradeId)
    if upgrade_type in self.state.upgrades:
        return 1
    level = None
    if "LEVEL" in upgrade_type.name:
        level = upgrade_type.name[-1]
    creationAbilityID = self._game_data.upgrades[upgrade_type.value].research_ability.id
    for structure in self.units.structure.ready:
        for order in structure.orders:
            if order.ability.id is creationAbilityID:
                if level and order.ability.button_name[-1] != level:
                    return 0
                return order.progress
    return 0

def build(

self, building, near, max_distance=20, unit=None, random_alternative=True, placement_step=2)

Build a building.

async def build(self, building: UnitTypeId, near: Union[Point2, Point3], max_distance: int=20, unit: Optional[Unit]=None, random_alternative: bool=True, placement_step: int=2):
    """Build a building."""
    if isinstance(near, Unit):
        near = near.position.to2
    elif near is not None:
        near = near.to2
    else:
        return
    p = await self.find_placement(building, near.rounded, max_distance, random_alternative, placement_step)
    if p is None:
        return ActionResult.CantFindPlacementLocation
    unit = unit or self.select_build_worker(p)
    if unit is None or not self.can_afford(building):
        return ActionResult.Error
    return await self.do(unit.build(building, p))

def can_afford(

self, item_id, check_supply_cost=True)

Tests if the player has enough resources to build a unit or cast an ability.

def can_afford(self, item_id: Union[UnitTypeId, UpgradeId, AbilityId], check_supply_cost: bool=True) -> "CanAffordWrapper":
    """Tests if the player has enough resources to build a unit or cast an ability."""
    enough_supply = True
    if isinstance(item_id, UnitTypeId):
        unit = self._game_data.units[item_id.value]
        cost = self._game_data.calculate_ability_cost(unit.creation_ability)
        if check_supply_cost:
            enough_supply = self.can_feed(item_id)
    elif isinstance(item_id, UpgradeId):
        cost = self._game_data.upgrades[item_id.value].cost
    else:
        cost = self._game_data.calculate_ability_cost(item_id)
    return CanAffordWrapper(cost.minerals <= self.minerals, cost.vespene <= self.vespene, enough_supply)

def can_cast(

self, unit, ability_id, target=None, only_check_energy_and_cooldown=False, cached_abilities_of_unit=None)

Tests if a unit has an ability available and enough energy to cast it. See data_pb2.py (line 161) for the numbers 1-5 to make sense

async def can_cast(self, unit: Unit, ability_id: AbilityId, target: Optional[Union[Unit, Point2, Point3]]=None, only_check_energy_and_cooldown: bool=False, cached_abilities_of_unit: List[AbilityId]=None) -> bool:
    """Tests if a unit has an ability available and enough energy to cast it.
    See data_pb2.py (line 161) for the numbers 1-5 to make sense"""
    assert isinstance(unit, Unit)
    assert isinstance(ability_id, AbilityId)
    assert isinstance(target, (type(None), Unit, Point2, Point3))
    # check if unit has enough energy to cast or if ability is on cooldown
    if cached_abilities_of_unit:
        abilities = cached_abilities_of_unit
    else:
        abilities = (await self.get_available_abilities([unit]))[0]
    if ability_id in abilities:
        if only_check_energy_and_cooldown:
            return True
        cast_range = self._game_data.abilities[ability_id.value]._proto.cast_range
        ability_target = self._game_data.abilities[ability_id.value]._proto.target
        # Check if target is in range (or is a self cast like stimpack)
        if ability_target == 1 or ability_target == Target.PointOrNone.value and isinstance(target, (Point2, Point3)) and unit.distance_to(target) <= cast_range: # cant replace 1 with "Target.None.value" because ".None" doesnt seem to be a valid enum name
            return True
        # Check if able to use ability on a unit
        elif ability_target in {Target.Unit.value, Target.PointOrUnit.value} and isinstance(target, Unit) and unit.distance_to(target) <= cast_range:
            return True
        # Check if able to use ability on a position
        elif ability_target in {Target.Point.value, Target.PointOrUnit.value} and isinstance(target, (Point2, Point3)) and unit.distance_to(target) <= cast_range:
            return True
    return False

def can_feed(

self, unit_type)

Checks if you have enough free supply to build the unit

def can_feed(self, unit_type: UnitTypeId) -> bool:
    """ Checks if you have enough free supply to build the unit """
    required = self._game_data.units[unit_type.value]._proto.food_required
    return required == 0 or self.supply_left >= required

def can_place(

self, building, position)

Tests if a building can be placed in the given location.

async def can_place(self, building: Union[AbilityData, AbilityId, UnitTypeId], position: Point2) -> bool:
    """Tests if a building can be placed in the given location."""
    assert isinstance(building, (AbilityData, AbilityId, UnitTypeId))
    if isinstance(building, UnitTypeId):
        building = self._game_data.units[building.value].creation_ability
    elif isinstance(building, AbilityId):
        building = self._game_data.abilities[building.value]
    r = await self._client.query_building_placement(building, [position])
    return r[0] == ActionResult.Success

def chat_send(

self, message)

Send a chat message.

async def chat_send(self, message: str):
    """Send a chat message."""
    assert isinstance(message, str)
    await self._client.chat_send(message, False)

def distribute_workers(

self)

Distributes workers across all the bases taken. WARNING: This is quite slow when there are lots of workers or multiple bases.

async def distribute_workers(self):
    """
    Distributes workers across all the bases taken.
    WARNING: This is quite slow when there are lots of workers or multiple bases.
    """
    # TODO:
    # OPTIMIZE: Assign idle workers smarter
    # OPTIMIZE: Never use same worker mutltiple times
    owned_expansions = self.owned_expansions
    worker_pool = []
    actions = []
    for idle_worker in self.workers.idle:
        mf = self.state.mineral_field.closest_to(idle_worker)
        actions.append(idle_worker.gather(mf))
    for location, townhall in owned_expansions.items():
        workers = self.workers.closer_than(20, location)
        actual = townhall.assigned_harvesters
        ideal = townhall.ideal_harvesters
        excess = actual - ideal
        if actual > ideal:
            worker_pool.extend(workers.random_group_of(min(excess, len(workers))))
            continue
    for g in self.geysers:
        workers = self.workers.closer_than(5, g)
        actual = g.assigned_harvesters
        ideal = g.ideal_harvesters
        excess = actual - ideal
        if actual > ideal:
            worker_pool.extend(workers.random_group_of(min(excess, len(workers))))
            continue
    for g in self.geysers:
        actual = g.assigned_harvesters
        ideal = g.ideal_harvesters
        deficit = ideal - actual
        for _ in range(deficit):
            if worker_pool:
                w = worker_pool.pop()
                if len(w.orders) == 1 and w.orders[0].ability.id is AbilityId.HARVEST_RETURN:
                    actions.append(w.move(g))
                    actions.append(w.return_resource(queue=True))
                else:
                    actions.append(w.gather(g))
    for location, townhall in owned_expansions.items():
        actual = townhall.assigned_harvesters
        ideal = townhall.ideal_harvesters
        deficit = ideal - actual
        for x in range(0, deficit):
            if worker_pool:
                w = worker_pool.pop()
                mf = self.state.mineral_field.closest_to(townhall)
                if len(w.orders) == 1 and w.orders[0].ability.id is AbilityId.HARVEST_RETURN:
                    actions.append(w.move(townhall))
                    actions.append(w.return_resource(queue=True))
                    actions.append(w.gather(mf, queue=True))
                else:
                    actions.append(w.gather(mf))
    await self.do_actions(actions)

def do(

self, action)

async def do(self, action):
    if not self.can_afford(action):
        logger.warning(f"Cannot afford action {action}")
        return ActionResult.Error
    r = await self._client.actions(action, game_data=self._game_data)
    if not r:  # success
        cost = self._game_data.calculate_ability_cost(action.ability)
        self.minerals -= cost.minerals
        self.vespene -= cost.vespene
    else:
        logger.error(f"Error: {r} (action: {action})")
    return r

def do_actions(

self, actions)

async def do_actions(self, actions: List["UnitCommand"]):
    if not actions:
        return None
    for action in actions:
        cost = self._game_data.calculate_ability_cost(action.ability)
        self.minerals -= cost.minerals
        self.vespene -= cost.vespene
    r = await self._client.actions(actions, game_data=self._game_data)
    return r

def expand_now(

self, building=None, max_distance=10, location=None)

Takes new expansion.

async def expand_now(self, building: UnitTypeId=None, max_distance: Union[int, float]=10, location: Optional[Point2]=None):
    """Takes new expansion."""
    if not building:
        # self.race is never Race.Random
        start_townhall_type = {Race.Protoss: UnitTypeId.NEXUS, Race.Terran: UnitTypeId.COMMANDCENTER, Race.Zerg: UnitTypeId.HATCHERY}
        building = start_townhall_type[self.race]
    assert isinstance(building, UnitTypeId)
    if not location:
        location = await self.get_next_expansion()
    
    if location:
        await self.build(building, near=location, max_distance=max_distance, random_alternative=False, placement_step=1)

def find_placement(

self, building, near, max_distance=20, random_alternative=True, placement_step=2)

Finds a placement location for building.

async def find_placement(self, building: UnitTypeId, near: Union[Unit, Point2, Point3], max_distance: int=20, random_alternative: bool=True, placement_step: int=2) -> Optional[Point2]:
    """Finds a placement location for building."""
    assert isinstance(building, (AbilityId, UnitTypeId))
    assert isinstance(near, Point2)
    if isinstance(building, UnitTypeId):
        building = self._game_data.units[building.value].creation_ability
    else:  # AbilityId
        building = self._game_data.abilities[building.value]
    if await self.can_place(building, near):
        return near
    if max_distance == 0:
        return None
    for distance in range(placement_step, max_distance, placement_step):
        possible_positions = [Point2(p).offset(near).to2 for p in (
                [(dx, -distance) for dx in range(-distance, distance + 1, placement_step)] +
                [(dx, distance) for dx in range(-distance, distance + 1, placement_step)] +
                [(-distance, dy) for dy in range(-distance, distance + 1, placement_step)] +
                [(distance, dy) for dy in range(-distance, distance + 1, placement_step)]
        )]
        res = await self._client.query_building_placement(building, possible_positions)
        possible = [p for r, p in zip(res, possible_positions) if r == ActionResult.Success]
        if not possible:
            continue
        if random_alternative:
            return random.choice(possible)
        else:
            return min(possible, key=lambda p: p.distance_to(near))
    return None

def get_available_abilities(

self, units, ignore_resource_requirements=False)

Returns available abilities of one or more units.

async def get_available_abilities(self, units: Union[List[Unit], Units], ignore_resource_requirements=False) -> List[List[AbilityId]]:
    """ Returns available abilities of one or more units. """
    # right know only checks cooldown, energy cost, and whether the ability has been researched
    return await self._client.query_available_abilities(units, ignore_resource_requirements)

def get_next_expansion(

self)

Find next expansion location.

async def get_next_expansion(self) -> Optional[Point2]:
    """Find next expansion location."""
    closest = None
    distance = math.inf
    for el in self.expansion_locations:
        def is_near_to_expansion(t):
            return t.position.distance_to(el) < self.EXPANSION_GAP_THRESHOLD
        if any(map(is_near_to_expansion, self.townhalls)):
            # already taken
            continue
        startp = self._game_info.player_start_location
        d = await self._client.query_pathing(startp, el)
        if d is None:
            continue
        if d < distance:
            distance = d
            closest = el
    return closest

def get_terrain_height(

self, pos)

Returns terrain height at a position. Caution: terrain height is not anywhere near a unit's z-coordinate.

def get_terrain_height(self, pos: Union[Point2, Point3, Unit]) -> int:
    """ Returns terrain height at a position. Caution: terrain height is not anywhere near a unit's z-coordinate. """
    assert isinstance(pos, (Point2, Point3, Unit))
    pos = pos.position.to2.rounded
    return self._game_info.terrain_height[pos] # returns int

def has_creep(

self, pos)

Returns True if there is creep on the grid point.

def has_creep(self, pos: Union[Point2, Point3, Unit]) -> bool:
    """ Returns True if there is creep on the grid point. """
    assert isinstance(pos, (Point2, Point3, Unit))
    pos = pos.position.to2.rounded
    return self.state.creep[pos] != 0

def in_pathing_grid(

self, pos)

Returns True if a unit can pass through a grid point.

def in_pathing_grid(self, pos: Union[Point2, Point3, Unit]) -> bool:
    """ Returns True if a unit can pass through a grid point. """
    assert isinstance(pos, (Point2, Point3, Unit))
    pos = pos.position.to2.rounded
    return self._game_info.pathing_grid[pos] == 0

def in_placement_grid(

self, pos)

Returns True if you can place something at a position. Remember, buildings usually use 2x2, 3x3 or 5x5 of these grid points. Caution: some x and y offset might be required, see ramp code: https://github.com/Dentosal/python-sc2/blob/master/sc2/game_info.py#L17-L18

def in_placement_grid(self, pos: Union[Point2, Point3, Unit]) -> bool:
    """ Returns True if you can place something at a position. Remember, buildings usually use 2x2, 3x3 or 5x5 of these grid points.
    Caution: some x and y offset might be required, see ramp code:
    https://github.com/Dentosal/python-sc2/blob/master/sc2/game_info.py#L17-L18 """
    assert isinstance(pos, (Point2, Point3, Unit))
    pos = pos.position.to2.rounded
    return self._game_info.placement_grid[pos] != 0

def is_visible(

self, pos)

Returns True if you have vision on a grid point.

def is_visible(self, pos: Union[Point2, Point3, Unit]) -> bool:
    """ Returns True if you have vision on a grid point. """
    # more info: https://github.com/Blizzard/s2client-proto/blob/9906df71d6909511907d8419b33acc1a3bd51ec0/s2clientprotocol/spatial.proto#L19
    assert isinstance(pos, (Point2, Point3, Unit))
    pos = pos.position.to2.rounded
    return self.state.visibility[pos] == 2

def issue_events(

self)

This function will be automatically run from main.py and triggers the following functions: - on_unit_created - on_unit_destroyed - on_building_construction_complete

async def issue_events(self):
    """ This function will be automatically run from main.py and triggers the following functions:
    - on_unit_created
    - on_unit_destroyed
    - on_building_construction_complete
    """
    await self._issue_unit_dead_events()
    await self._issue_unit_added_events()
    for unit in self.units.structure:
        await self._issue_building_complete_event(unit)

def on_building_construction_complete(

self, unit)

Override this in your bot class.

async def on_building_construction_complete(self, unit: Unit):
    """ Override this in your bot class. """
    pass

def on_end(

self, game_result)

Ran at the end of a game.

def on_end(self, game_result: Result):
    """Ran at the end of a game."""
    pass

def on_start(

self)

Allows initializing the bot when the game data is available.

def on_start(self):
    """Allows initializing the bot when the game data is available."""
    pass

def on_step(

self, iteration)

Ran on every game step (looped in realtime mode).

async def on_step(self, iteration: int):
    """Ran on every game step (looped in realtime mode)."""
    raise NotImplementedError

def on_unit_created(

self, unit)

Override this in your bot class.

async def on_unit_created(self, unit: Unit):
    """ Override this in your bot class. """
    pass

def on_unit_destroyed(

self, unit_tag)

Override this in your bot class.

async def on_unit_destroyed(self, unit_tag):
    """ Override this in your bot class. """
    pass

def select_build_worker(

self, pos, force=False)

Select a worker to build a bulding with.

def select_build_worker(self, pos: Union[Unit, Point2, Point3], force: bool=False) -> Optional[Unit]:
    """Select a worker to build a bulding with."""
    workers = self.workers.closer_than(20, pos) or self.workers
    for worker in workers.prefer_close_to(pos).prefer_idle:
        if not worker.orders or len(worker.orders) == 1 and worker.orders[0].ability.id in {AbilityId.MOVE,
                                                                                            AbilityId.HARVEST_GATHER,
                                                                                            AbilityId.HARVEST_RETURN}:
            return worker
    return workers.random if force else None

Instance variables

var enemy_race

var enemy_start_locations

Possible start locations for enemies.

var expansion_locations

List of possible expansion locations.

var game_info

var known_enemy_structures

List of known enemy units, structures only.

var known_enemy_units

List of known enemy units, including structures.

var main_base_ramp

Returns the Ramp instance of the closest main-ramp to start location. Look in game_info.py for more information

var owned_expansions

List of expansions owned by the player.

var start_location

var time

Returns time in seconds, assumes the game is played on 'faster'

class CanAffordWrapper

class CanAffordWrapper:
    def __init__(self, can_afford_minerals, can_afford_vespene, have_enough_supply):
        self.can_afford_minerals = can_afford_minerals
        self.can_afford_vespene = can_afford_vespene
        self.have_enough_supply = have_enough_supply

    def __bool__(self):
        return self.can_afford_minerals and self.can_afford_vespene and self.have_enough_supply

    @property
    def action_result(self):
        if not self.can_afford_vespene:
            return ActionResult.NotEnoughVespene
        elif not self.can_afford_minerals:
            return ActionResult.NotEnoughMinerals
        elif not self.have_enough_supply:
            return ActionResult.NotEnoughFood
        else:
            return None

Ancestors (in MRO)

Static methods

def __init__(

self, can_afford_minerals, can_afford_vespene, have_enough_supply)

Initialize self. See help(type(self)) for accurate signature.

def __init__(self, can_afford_minerals, can_afford_vespene, have_enough_supply):
    self.can_afford_minerals = can_afford_minerals
    self.can_afford_vespene = can_afford_vespene
    self.have_enough_supply = have_enough_supply

Instance variables

var action_result

var can_afford_minerals

var can_afford_vespene

var have_enough_supply