├── .coveragerc ├── .flake8 ├── .github └── workflows │ ├── build_c_extension.yml │ ├── lint.yml │ ├── orchestrator.yml │ ├── release.yml │ ├── semantic_pull_request.yml │ └── tests.yml ├── .gitignore ├── .versionrc ├── CHANGELOG.md ├── LICENSE ├── Makefile ├── README.md ├── build.py ├── conftest.py ├── examples └── MassReaper │ ├── README.md │ ├── bot │ ├── consts.py │ ├── influence_costs.py │ ├── main.py │ ├── pathing.py │ └── reapers.py │ ├── ladder.py │ └── run.py ├── extension └── ma_ext.c ├── ladder_build.sh ├── ladder_build └── Dockerfile ├── map_analyzer ├── Debugger.py ├── MapData.py ├── Pather.py ├── Polygon.py ├── Region.py ├── __init__.py ├── cext │ ├── __init__.py │ ├── mapanalyzerext.so │ └── wrapper.py ├── constants.py ├── constructs.py ├── destructibles.py ├── exceptions.py ├── pickle_gameinfo │ ├── 2000AtmospheresAIE.xz │ ├── AbyssalReefLE.xz │ ├── AncientCisternAIE.xz │ ├── BerlingradAIE.xz │ ├── BlackburnAIE.xz │ ├── CuriousMindsAIE.xz │ ├── DeathAuraLE.xz │ ├── EternalEmpireLE.xz │ ├── GlitteringAshesAIE.xz │ ├── GoldenWallLE.xz │ ├── GoldenauraAIE.xz │ ├── HardwireAIE.xz │ ├── IceandChromeLE.xz │ ├── InfestationStationAIE.xz │ ├── JagannathaAIE.xz │ ├── LightshadeAIE.xz │ ├── NightshadeLE.xz │ ├── PillarsofGoldLE.xz │ ├── RomanticideAIE.xz │ ├── RoyalBloodAIE.xz │ └── SimulacrumLE.xz ├── settings.py └── utils.py ├── monkeytest.py ├── package-lock.json ├── package.json ├── pf_perf.py ├── poetry.lock ├── pyproject.toml ├── pytest.ini ├── tests ├── __init__.py ├── mocksetup.py ├── monkeytest.py ├── pathing_grid.txt ├── test_c_extension.py ├── test_docs.py ├── test_pathing.py └── test_suite.py └── vb.py /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | omit = 3 | MapAnalyzer/sc2pathlibp/* 4 | 5 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | # .flake8 2 | [flake8] 3 | max-line-length = 88 4 | extend-ignore = E203, F631 5 | per-file-ignores = 6 | */__init__.py: F401 7 | exclude = 8 | tests/test_docs.py 9 | run.py # should be removed? 10 | -------------------------------------------------------------------------------- /.github/workflows/build_c_extension.yml: -------------------------------------------------------------------------------- 1 | name: BuildCExtension 2 | on: 3 | push: 4 | branches: [ master, develop ] 5 | 6 | jobs: 7 | build: 8 | name: Build release 9 | 10 | runs-on: ${{ matrix.os }} 11 | strategy: 12 | fail-fast: false 13 | matrix: 14 | os: [windows-latest, macos-latest, ubuntu-latest] 15 | python-version: ['3.11'] 16 | 17 | steps: 18 | # check-out repo 19 | - name: Checkout repository 20 | uses: actions/checkout@v3 21 | with: 22 | ref: ${{ github.head_ref }} 23 | # install poetry 24 | - name: Install poetry 25 | run: pipx install poetry==1.5 26 | # set-up python with cache 27 | - name: Setup Python ${{ matrix.python-version }} 28 | uses: actions/setup-python@v4 29 | with: 30 | python-version: ${{ matrix.python-version }} 31 | cache: 'poetry' 32 | # Install requirements and build extension 33 | - name: Install requirements and build extension 34 | run: | 35 | poetry install --with dev 36 | python -c "import shutil, glob, os; [shutil.copy(f, '.') for f in glob.glob('mapanalyzerext*') if not os.path.exists(os.path.join('.', os.path.basename(f)))]" 37 | - uses: actions/upload-artifact@v3 38 | with: 39 | name: ${{ matrix.os }}_python${{ matrix.python-version }} 40 | path: | 41 | ./mapanalyzerext* 42 | mapanalyzerext* 43 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Linting 2 | 3 | on: 4 | push: 5 | branches: [ develop ] 6 | pull_request: 7 | branches: [ master, develop ] 8 | workflow_call: 9 | 10 | jobs: 11 | build: 12 | runs-on: ${{ matrix.os }} 13 | strategy: 14 | matrix: 15 | os: [ ubuntu-latest ] 16 | python-version: [ 3.11 ] 17 | 18 | steps: 19 | # check-out repo 20 | - uses: actions/checkout@v2 21 | - name: Set up ${{ matrix.python-version }} 22 | uses: actions/setup-python@v2 23 | with: 24 | python-version: ${{ matrix.python-version }} 25 | - name: Install dependencies 26 | run: | 27 | pipx install poetry==1.6.1 28 | poetry install --with dev 29 | # run linters 30 | - name: Run linters 31 | run: | 32 | set -o pipefail 33 | poetry run make lint 34 | -------------------------------------------------------------------------------- /.github/workflows/orchestrator.yml: -------------------------------------------------------------------------------- 1 | name: Orchestrator 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | call-testing-pipeline: 10 | name: Testing 11 | uses: ./.github/workflows/tests.yml 12 | call-linting-pipeline: 13 | name: Linting 14 | uses: ./.github/workflows/lint.yml 15 | call-release-pipeline: 16 | name: Release 17 | needs: 18 | - call-testing-pipeline 19 | - call-linting-pipeline 20 | uses: ./.github/workflows/release.yml 21 | secrets: inherit 22 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | workflow_call: 5 | 6 | jobs: 7 | release: 8 | runs-on: ubuntu-latest 9 | concurrency: release 10 | 11 | steps: 12 | # check-out repo 13 | - name: Checkout repository 14 | uses: actions/checkout@v3 15 | with: 16 | ref: ${{ github.head_ref }} 17 | # install poetry 18 | - name: Install poetry 19 | run: pipx install poetry==1.6.1 20 | # set-up python with cache 21 | - name: Setup Python 3.11 22 | uses: actions/setup-python@v4 23 | with: 24 | python-version: '3.11' 25 | cache: 'poetry' 26 | # install requirements 27 | - name: Install requirements 28 | run: poetry install --only semver 29 | # semantic release 30 | - name: Python Semantic Release 31 | env: 32 | GH_TOKEN: ${{ secrets.GH_TOKEN_MA }} 33 | run: | 34 | set -o pipefail 35 | # Set git details 36 | git config --global user.name "github-actions" 37 | git config --global user.email "github-actions@github.com" 38 | # run semantic-release 39 | poetry run semantic-release publish -v DEBUG -D commit_author="github-actions " 40 | -------------------------------------------------------------------------------- /.github/workflows/semantic_pull_request.yml: -------------------------------------------------------------------------------- 1 | name: "Lint PR" 2 | on: 3 | pull_request_target: 4 | types: 5 | - opened 6 | - edited 7 | - synchronize 8 | jobs: 9 | main: 10 | name: Validate PR title 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: amannn/action-semantic-pull-request@v5 14 | env: 15 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 16 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: RunTests 2 | 3 | on: 4 | push: 5 | branches: [ develop ] 6 | pull_request: 7 | branches: [ master, develop ] 8 | workflow_call: 9 | 10 | 11 | jobs: 12 | build: 13 | runs-on: ${{ matrix.os }} 14 | strategy: 15 | matrix: 16 | os: [ubuntu-latest, windows-latest, macos-latest] 17 | python-version: [3.11] 18 | 19 | steps: 20 | - uses: actions/checkout@v2 21 | - name: Set up ${{ matrix.python-version }} 22 | uses: actions/setup-python@v2 23 | with: 24 | python-version: ${{ matrix.python-version }} 25 | - name: Install dependencies 26 | run: | 27 | pipx install poetry==1.6.1 28 | poetry install --with dev 29 | - name: Test with pytest + Coverage 30 | run: | 31 | poetry run pytest --html=html/${{ matrix.os }}-test-results-${{ matrix.python-version }}.html 32 | - name: Upload pytest test results 33 | uses: actions/upload-artifact@v2 34 | with: 35 | name: pytest-results-${{ matrix.os }}-${{ matrix.python-version }} 36 | path: html/${{ matrix.os }}-test-results-${{ matrix.python-version }}.html 37 | # Use always() to always run this step to publish test results when there are test failures 38 | if: ${{ always() }} 39 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by .ignore support plugin (hsz.mobi) 2 | ### Example user template template 3 | ### Example user template 4 | notes.md 5 | *.png 6 | # IntelliJ project files 7 | .idea 8 | *.iml 9 | out 10 | gen 11 | ### Python template 12 | # Byte-compiled / optimized / DLL files 13 | __pycache__/ 14 | *.py[cod] 15 | *$py.class 16 | 17 | # C extensions 18 | *.so 19 | 20 | # Distribution / packaging 21 | node_modules/ 22 | .Python 23 | build/ 24 | develop-eggs/ 25 | dist/ 26 | downloads/ 27 | eggs/ 28 | .eggs/ 29 | lib/ 30 | lib64/ 31 | parts/ 32 | sdist/ 33 | var/ 34 | wheels/ 35 | pip-wheel-metadata/ 36 | share/python-wheels/ 37 | *.egg-info/ 38 | .installed.cfg 39 | *.egg 40 | MANIFEST 41 | 42 | # PyInstaller 43 | # Usually these files are written by a python script from a template 44 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 45 | *.manifest 46 | *.spec 47 | 48 | # Installer logs 49 | pip-log.txt 50 | pip-delete-this-directory.txt 51 | 52 | # Unit test / coverage reports 53 | htmlcov/ 54 | .tox/ 55 | .nox/ 56 | .coverage 57 | .coverage.* 58 | .cache 59 | nosetests.xml 60 | coverage.xml 61 | *.cover 62 | .hypothesis/ 63 | .pytest_cache/ 64 | .benchmarks/ 65 | 66 | # Translations 67 | *.mo 68 | *.pot 69 | 70 | # Django stuff: 71 | *.log 72 | local_settings.py 73 | db.sqlite3 74 | *.sqlite3 75 | 76 | 77 | # Flask stuff: 78 | instance/ 79 | .webassets-cache 80 | 81 | # Scrapy stuff: 82 | .scrapy 83 | 84 | # Sphinx documentation 85 | docs/_build/ 86 | *.html 87 | # PyBuilder 88 | target/ 89 | 90 | # Jupyter Notebook 91 | .ipynb_checkpoints 92 | *.ipynb 93 | 94 | # IPython 95 | profile_default/ 96 | ipython_config.py 97 | 98 | # pyenv 99 | .python-version 100 | 101 | # pipenv 102 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 103 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 104 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 105 | # install all needed dependencies. 106 | #Pipfile.lock 107 | 108 | # celery beat schedule file 109 | celerybeat-schedule 110 | 111 | # SageMath parsed files 112 | *.sage.py 113 | 114 | # Environments 115 | .env 116 | .venv 117 | env/ 118 | venv/ 119 | ENV/ 120 | env.bak/ 121 | venv.bak/ 122 | 123 | # Spyder project settings 124 | .spyderproject 125 | .spyproject 126 | 127 | # Rope project settings 128 | .ropeproject 129 | 130 | # mkdocs documentation 131 | /site 132 | 133 | # mypy 134 | .mypy_cache/ 135 | .dmypy.json 136 | dmypy.json 137 | 138 | # Pyre type checker 139 | .pyre/ 140 | 141 | /tmap.py 142 | -------------------------------------------------------------------------------- /.versionrc: -------------------------------------------------------------------------------- 1 | { 2 | "types": [ 3 | {"type":"feat","section":"Features"}, 4 | {"type":"fix","section":"Bug Fixes"}, 5 | {"type":"perf","section":"Performance Improvements"}, 6 | {"type":"refactor","section":"Refactoring"}, 7 | {"type":"test","section":"Tests"}, 8 | {"type":"build","section":"Build System"}, 9 | {"type":"docs","section":"Documentation"} 10 | ] 11 | } -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | format-black: 2 | @black . 3 | format-isort: 4 | @isort . 5 | lint-black: 6 | @black . --check 7 | lint-isort: 8 | @isort . --check 9 | lint-flake8: 10 | @flake8 . 11 | 12 | lint: lint-black lint-isort lint-flake8 13 | 14 | current-version: 15 | @semantic-release print-version --current 16 | 17 | next-version: 18 | @semantic-release print-version --next 19 | 20 | current-changelog: 21 | @semantic-release changelog --released 22 | 23 | next-changelog: 24 | @semantic-release changelog --unreleased 25 | 26 | publish-noop: 27 | @semantic-release publish --noop 28 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SC2MapAnalysis 2 | 3 | * [Changelog](https://github.com/spudde123/SC2MapAnalysis/blob/master/CHANGELOG.md) 4 | * ![](https://img.shields.io/badge/Documentation-latest-green?style=plastic&logo=appveyor) 5 | [Documentation](https://eladyaniv01.github.io/SC2MapAnalysis/) 6 | 7 | ## Summary 8 | 9 | A plugin for Python SC2 API [BurnySc2](https://github.com/BurnySc2/python-sc2/) 10 | 11 | 12 | ## Why Do we need this ? 13 | 14 | 15 | This module is inspired by plays like this one [TY map positioning](https://www.youtube.com/watch?v=NUQsAWIBTSk&start=458) 16 | (notice how the army splits into groups, covering different areas, tanks are tucked in corners, and so on) 17 | 18 | Hopefully with the interface provided here, you will be able to build plays like that one! 19 | 20 | Thanks A lot to [DrInfy](https://github.com/DrInfy) for solving one of the biggest challenges, finding rare choke points. 21 | 22 | Check out his work 23 | 24 | * [Sharpy](https://github.com/DrInfy/sharpy-sc2) for rapid bot development. 25 | 26 | * [sc2pathlib](https://github.com/DrInfy/sc2-pathlib) a high performance rust module with python interface for pathfinding 27 | 28 | 29 | More Examples reside in the [Documentation](https://eladyaniv01.github.io/SC2MapAnalysis/) 30 | 31 | See [here](./examples/MassReaper/README.md) for an example `map_analyzer` reaper bot showing pathing and influence in action. 32 | 33 | Example: 34 | ```python 35 | import pickle 36 | import lzma 37 | from map_analyzer import MapData 38 | from map_analyzer.utils import import_bot_instance 39 | 40 | #if its from BurnySc2 it is compressed 41 | # https://github.com/BurnySc2/python-sc2/tree/develop/test/pickle_data 42 | YOUR_FILE_PATH = 'some_directory/map_file' 43 | with lzma.open(YOUR_FILE_PATH, "rb") as f: 44 | raw_game_data, raw_game_info, raw_observation = pickle.load(f) 45 | 46 | # mocking a bot object to initalize the map, this is for when you want to do this while not in a game, 47 | # if you want to use it in a game just pass in the bot object like shown below 48 | 49 | bot = import_bot_instance(raw_game_data, raw_game_info, raw_observation) 50 | 51 | 52 | # And then you can instantiate a MapData Object like so 53 | map_data = MapData(bot) 54 | 55 | 56 | # plot the entire labeled map 57 | map_data.plot_map() 58 | 59 | # red dots or X are vision blockers, 60 | # ramps are marked with white dots 61 | # ramp top center is marked with '^' 62 | # gas geysers are yellow spades 63 | # MDRampss are marked with R 64 | # height span is with respect to : light = high , dark = low 65 | # ChokeArea is marked with green heart suites 66 | # Corners are marked with a red 'V' 67 | ``` 68 | 69 | 70 | 71 | Tested Maps ( [AiArena](https://www.sc2ai.com/) ) : See `map_analyzer/pickle_game_info` for all tested maps. 72 | 73 | # Getting Started 74 | 75 | ## Bot Authors 76 | 77 | If you already have a [BurnySc2](https://github.com/BurnySc2/python-sc2/) development environment setup, you're likely 78 | equipped with all the necessary dependencies Therefore, integrating map_analyzer into your existing bot is a 79 | straightforward process requiring just a few simple steps. 80 | 81 | If you're a new bot author, please set up a new [BurnySc2](https://github.com/BurnySc2/python-sc2/) bot development 82 | environment before installing `map_analyzer`. 83 | 84 | ### Installation 85 | 86 | 1. Clone or download this repo 87 | 2. Copy the `map_analyzer` directory and place it in the root of your bot directory: 88 | 89 | ``` 90 | MyBot 91 | ├── map_analyzer 92 | │ ├── … dependencies for map_analyzer 93 | │ … your other bot files and folders here 94 | ``` 95 | 96 | 3. map_analyzer relies on a pathing extension written in C, this can be built locally or downloaded from github actions. 97 | If you're on a debian based OS you may be able to skip this step as the repo contains a linux binary already included 98 | in the `map_analyzer` folder.

99 | Method 1: Without needing C++ build tools

100 | Check the most recent [BuildCExtension](https://github.com/spudde123/SC2MapAnalysis/actions/workflows/build_c_extension.yml) 101 | Github Action workflow. Then scroll to the bottom to download the artifact for your OS: 102 | ![c_workflow](https://github.com/spudde123/SC2MapAnalysis/assets/63355562/65e08208-8f82-44ee-bf84-3b79d1271d76) 103 |
104 | Download the artifact and copy the binary to `MyBot/map_analyzer/cext/`

105 | Method 2: Build the project locally
106 | If you're on Windows, make sure [Microsoft C++ Build Tools](https://visualstudio.microsoft.com/visual-cpp-build-tools/) 107 | are installed before proceeding, then: 108 | - Install [Poetry](https://python-poetry.org/) for example: `pip install poetry` 109 | - In the root folder of this repo run the following: 110 | `poetry install` 111 |

112 | If successful this will compile a binary in the build directory, for example: 113 | `SC2MapAnalysis\build\lib.win-amd64-cpython-311\mapanalyzerext.cp311-win_amd64.pyd` 114 |
115 | Copy this binary to `MyBot/map_analyzer/cext/mapanalyzerext.cp311-win_amd64.pyd` 116 | 117 | 118 | 4. In your bot initiate the `map_analyzer` module: 119 | ```python 120 | from map_analyzer import MapData 121 | from sc2.bot_ai import BotAI 122 | 123 | class MyBot(BotAI): 124 | map_data: MapData 125 | 126 | async def on_start(self) -> None: 127 | self.map_data = MapData(self) 128 | 129 | async def on_step(self, iteration: int): 130 | pass 131 | ``` 132 | 133 | 5. Uploading to [AiArena](https://ai-arena.net/) and tournaments -
134 | No further setup is required, just include the `map_analyzer` folder in your bot zip. 135 | 136 | 137 | ## Contributors or to run examples 138 | 139 | If you're interested in contributing or would like to run tests then the full dev environment should be setup: 140 | 1. Install [Poetry](https://python-poetry.org/) for example: `pip install poetry` 141 | 2. `poetry install --with dev` - This will install all development dependencies, build the C extension 142 | and create a new environment. 143 | Useful poetry environment commands:
144 | `poetry env list --full-path -` - Use this to configure your IDE to recognise the environment
145 | `poetry env remove `
146 | `poetry shell` 147 | 3. Check your environment is working by running the example bot:
148 | `poetry run examples/MassReaper/run.py` 149 | 150 | ### Run tests 151 | (github workflow to check this on PR) 152 | 153 | `poetry run pytest` 154 | 155 | ### Autoformatters and linting 156 | (github workflow to check these on PR) 157 | 158 | `poetry run black .` 159 | 160 | `poetry run isort .` 161 | 162 | `poetry run flake8 .` 163 | 164 | ### Contributing 165 | To faciliatate automated releases, [conventional commits](https://www.conventionalcommits.org/en/v1.0.0/) guideline should be followed. 166 | Example git commits: 167 | 168 | Feature: 169 | `feat: find path with multiple grids` 170 | 171 | Bugfix: 172 | `fix: correct weight cost on cliff` 173 | 174 | Pull request titles should follow these guidelines too, this enables the automatic release and changelogs to work. 175 | There is a github workflow to enforce this. 176 | 177 | Example PR title: 178 | 179 | `feat: find path with multiple grids` 180 | 181 | 182 | 183 | 184 | 185 | -------------------------------------------------------------------------------- /build.py: -------------------------------------------------------------------------------- 1 | """Build script.""" 2 | from distutils.errors import CCompilerError, DistutilsExecError, DistutilsPlatformError 3 | 4 | import numpy 5 | from setuptools import Extension 6 | from setuptools.command.build_ext import build_ext 7 | 8 | extensions = [ 9 | Extension( 10 | "mapanalyzerext", 11 | sources=["extension/ma_ext.c"], 12 | include_dirs=[numpy.get_include()], 13 | ), 14 | ] 15 | 16 | 17 | class BuildFailed(Exception): 18 | pass 19 | 20 | 21 | class ExtBuilder(build_ext): 22 | def run(self): 23 | try: 24 | build_ext.run(self) 25 | except (DistutilsPlatformError, FileNotFoundError): 26 | pass 27 | 28 | def build_extension(self, ext): 29 | try: 30 | build_ext.build_extension(self, ext) 31 | except (CCompilerError, DistutilsExecError, DistutilsPlatformError, ValueError): 32 | pass 33 | 34 | 35 | def build(setup_kwargs): 36 | setup_kwargs.update( 37 | {"ext_modules": extensions, "cmdclass": {"build_ext": ExtBuilder}} 38 | ) 39 | -------------------------------------------------------------------------------- /conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from tests.test_docs import map_data 4 | 5 | # 6 | # def pytest_collection_finish(session): 7 | # """Handle the pytest collection finish hook: configure pyannotate. 8 | # Explicitly delay importing `collect_types` until all tests have 9 | # been collected. This gives gevent a chance to monkey patch the 10 | # world before importing pyannotate. 11 | # """ 12 | # from pyannotate_runtime import collect_types 13 | # 14 | # collect_types.init_types_collection() 15 | # 16 | # 17 | # @pytest.fixture(autouse=True) 18 | # def collect_types_fixture(): 19 | # from pyannotate_runtime import collect_types 20 | # 21 | # collect_types.start() 22 | # yield 23 | # collect_types.stop() 24 | # 25 | # 26 | # def pytest_sessionfinish(session, exitstatus): 27 | # from pyannotate_runtime import collect_types 28 | # 29 | # collect_types.dump_stats("type_info.json") 30 | 31 | 32 | @pytest.fixture(autouse=True) 33 | def add_map_data(doctest_namespace): 34 | doctest_namespace["self"] = map_data 35 | -------------------------------------------------------------------------------- /examples/MassReaper/README.md: -------------------------------------------------------------------------------- 1 | # Mass Reaper SC2MapAnalyzer example bot 2 | 3 | Using the mass reaper example in [python-sc2](https://github.com/BurnySc2/python-sc2/blob/develop/examples/terran/mass_reaper.py) as a starting point, we use the pathing module in map_analyzer to improve the reaper micro. This is achieved by adding enemy influence to pathing grids to allow reapers micro a bit more precisely. 4 | 5 | This example should work with the [Ai-Arena ladder](https://aiarena.net/) ladder using the following steps: 6 | 7 | 1. Choose a new name and give your bot some character! 8 | 2. Rename `MassReaper` references in this example to `YourBotName` 9 | 3. Copy `SC2MapAnalysis/map_analyzer` folder inside this folder since aiarena environment does not have MA installed 10 | 4. Copy the `sc2` folder from the [python-sc2](https://github.com/BurnySc2/python-sc2) repo and put it inside this folder. This step may be optional but ensures our bot ships with the latest version of `python-sc2` 11 | 5. Zip the entire folder, make sure all files are in the root folder 12 | 6. Create an account on https://aiarena.net/ and create a new bot 13 | 7. Upload the zip to aiarena and join competitions or request matches 14 | 15 | See the ai-arena wiki for more information https://aiarena.net/wiki/bot-development/getting-started/#wiki-toc-python 16 | -------------------------------------------------------------------------------- /examples/MassReaper/bot/consts.py: -------------------------------------------------------------------------------- 1 | from typing import Set 2 | 3 | from sc2.ids.unit_typeid import UnitTypeId 4 | 5 | ATTACK_TARGET_IGNORE: Set[UnitTypeId] = { 6 | UnitTypeId.LARVA, 7 | UnitTypeId.EGG, 8 | UnitTypeId.CHANGELING, 9 | UnitTypeId.CHANGELINGMARINE, 10 | UnitTypeId.CHANGELINGMARINESHIELD, 11 | UnitTypeId.CHANGELINGZEALOT, 12 | UnitTypeId.CHANGELINGZERGLING, 13 | UnitTypeId.CHANGELINGZERGLINGWINGS, 14 | } 15 | 16 | ALL_STRUCTURES: Set[UnitTypeId] = { 17 | UnitTypeId.ARMORY, 18 | UnitTypeId.ASSIMILATOR, 19 | UnitTypeId.ASSIMILATORRICH, 20 | UnitTypeId.AUTOTURRET, 21 | UnitTypeId.BANELINGNEST, 22 | UnitTypeId.BARRACKS, 23 | UnitTypeId.BARRACKSFLYING, 24 | UnitTypeId.BARRACKSREACTOR, 25 | UnitTypeId.BARRACKSTECHLAB, 26 | UnitTypeId.BUNKER, 27 | UnitTypeId.BYPASSARMORDRONE, 28 | UnitTypeId.COMMANDCENTER, 29 | UnitTypeId.COMMANDCENTERFLYING, 30 | UnitTypeId.CREEPTUMOR, 31 | UnitTypeId.CREEPTUMORBURROWED, 32 | UnitTypeId.CREEPTUMORQUEEN, 33 | UnitTypeId.CYBERNETICSCORE, 34 | UnitTypeId.DARKSHRINE, 35 | UnitTypeId.ELSECARO_COLONIST_HUT, 36 | UnitTypeId.ENGINEERINGBAY, 37 | UnitTypeId.EVOLUTIONCHAMBER, 38 | UnitTypeId.EXTRACTOR, 39 | UnitTypeId.EXTRACTORRICH, 40 | UnitTypeId.FACTORY, 41 | UnitTypeId.FACTORYFLYING, 42 | UnitTypeId.FACTORYREACTOR, 43 | UnitTypeId.FACTORYTECHLAB, 44 | UnitTypeId.FLEETBEACON, 45 | UnitTypeId.FORGE, 46 | UnitTypeId.FUSIONCORE, 47 | UnitTypeId.GATEWAY, 48 | UnitTypeId.GHOSTACADEMY, 49 | UnitTypeId.GREATERSPIRE, 50 | UnitTypeId.HATCHERY, 51 | UnitTypeId.HIVE, 52 | UnitTypeId.HYDRALISKDEN, 53 | UnitTypeId.INFESTATIONPIT, 54 | UnitTypeId.LAIR, 55 | UnitTypeId.LURKERDENMP, 56 | UnitTypeId.MISSILETURRET, 57 | UnitTypeId.NEXUS, 58 | UnitTypeId.NYDUSCANAL, 59 | UnitTypeId.NYDUSCANALATTACKER, 60 | UnitTypeId.NYDUSCANALCREEPER, 61 | UnitTypeId.NYDUSNETWORK, 62 | UnitTypeId.ORACLESTASISTRAP, 63 | UnitTypeId.ORBITALCOMMAND, 64 | UnitTypeId.ORBITALCOMMANDFLYING, 65 | UnitTypeId.PHOTONCANNON, 66 | UnitTypeId.PLANETARYFORTRESS, 67 | UnitTypeId.POINTDEFENSEDRONE, 68 | UnitTypeId.PYLON, 69 | UnitTypeId.PYLONOVERCHARGED, 70 | UnitTypeId.RAVENREPAIRDRONE, 71 | UnitTypeId.REACTOR, 72 | UnitTypeId.REFINERY, 73 | UnitTypeId.REFINERYRICH, 74 | UnitTypeId.RESOURCEBLOCKER, 75 | UnitTypeId.ROACHWARREN, 76 | UnitTypeId.ROBOTICSBAY, 77 | UnitTypeId.ROBOTICSFACILITY, 78 | UnitTypeId.SENSORTOWER, 79 | UnitTypeId.SHIELDBATTERY, 80 | UnitTypeId.SPAWNINGPOOL, 81 | UnitTypeId.SPINECRAWLER, 82 | UnitTypeId.SPINECRAWLERUPROOTED, 83 | UnitTypeId.SPIRE, 84 | UnitTypeId.SPORECRAWLER, 85 | UnitTypeId.SPORECRAWLERUPROOTED, 86 | UnitTypeId.STARGATE, 87 | UnitTypeId.STARPORT, 88 | UnitTypeId.STARPORTFLYING, 89 | UnitTypeId.STARPORTREACTOR, 90 | UnitTypeId.STARPORTTECHLAB, 91 | UnitTypeId.SUPPLYDEPOT, 92 | UnitTypeId.SUPPLYDEPOTLOWERED, 93 | UnitTypeId.TECHLAB, 94 | UnitTypeId.TEMPLARARCHIVE, 95 | UnitTypeId.TWILIGHTCOUNCIL, 96 | UnitTypeId.ULTRALISKCAVERN, 97 | UnitTypeId.WARPGATE, 98 | } 99 | -------------------------------------------------------------------------------- /examples/MassReaper/bot/influence_costs.py: -------------------------------------------------------------------------------- 1 | """ 2 | This is used in `pathing.py` 3 | These are units where we want to define our own weights and costs 4 | Some of these units don't have a "weapon" in the API so we provide 5 | values for range and cost here 6 | Some of these units might have a weapon but we can declare custom values 7 | For any units not declared here we take values from the API instead 8 | when adding influence 9 | """ 10 | 11 | from typing import Dict 12 | 13 | from sc2.ids.unit_typeid import UnitTypeId 14 | 15 | INFLUENCE_COSTS: Dict[UnitTypeId, Dict] = { 16 | UnitTypeId.ADEPT: {"AirCost": 0, "GroundCost": 9, "AirRange": 0, "GroundRange": 5}, 17 | UnitTypeId.ADEPTPHASESHIFT: { 18 | "AirCost": 0, 19 | "GroundCost": 9, 20 | "AirRange": 0, 21 | "GroundRange": 5, 22 | }, 23 | UnitTypeId.AUTOTURRET: { 24 | "AirCost": 31, 25 | "GroundCost": 31, 26 | "AirRange": 7, 27 | "GroundRange": 7, 28 | }, 29 | UnitTypeId.ARCHON: { 30 | "AirCost": 40, 31 | "GroundCost": 40, 32 | "AirRange": 3, 33 | "GroundRange": 3, 34 | }, 35 | UnitTypeId.BANELING: { 36 | "AirCost": 0, 37 | "GroundCost": 20, 38 | "AirRange": 0, 39 | "GroundRange": 3, 40 | }, 41 | UnitTypeId.BANSHEE: { 42 | "AirCost": 0, 43 | "GroundCost": 12, 44 | "AirRange": 0, 45 | "GroundRange": 6, 46 | }, 47 | UnitTypeId.BATTLECRUISER: { 48 | "AirCost": 31, 49 | "GroundCost": 50, 50 | "AirRange": 6, 51 | "GroundRange": 6, 52 | }, 53 | UnitTypeId.BUNKER: { 54 | "AirCost": 22, 55 | "GroundCost": 22, 56 | "AirRange": 6, 57 | "GroundRange": 6, 58 | }, 59 | UnitTypeId.CARRIER: { 60 | "AirCost": 20, 61 | "GroundCost": 20, 62 | "AirRange": 11, 63 | "GroundRange": 11, 64 | }, 65 | UnitTypeId.CORRUPTOR: { 66 | "AirCost": 10, 67 | "GroundCost": 0, 68 | "AirRange": 6, 69 | "GroundRange": 0, 70 | }, 71 | UnitTypeId.CYCLONE: { 72 | "AirCost": 27, 73 | "GroundCost": 27, 74 | "AirRange": 7, 75 | "GroundRange": 7, 76 | }, 77 | UnitTypeId.GHOST: { 78 | "AirCost": 10, 79 | "GroundCost": 10, 80 | "AirRange": 6, 81 | "GroundRange": 6, 82 | }, 83 | UnitTypeId.HELLION: { 84 | "AirCost": 0, 85 | "GroundCost": 8, 86 | "AirRange": 0, 87 | "GroundRange": 8, 88 | }, 89 | UnitTypeId.HYDRALISK: { 90 | "AirCost": 20, 91 | "GroundCost": 20, 92 | "AirRange": 6, 93 | "GroundRange": 6, 94 | }, 95 | UnitTypeId.INFESTOR: { 96 | "AirCost": 30, 97 | "GroundCost": 30, 98 | "AirRange": 10, 99 | "GroundRange": 10, 100 | }, 101 | UnitTypeId.LIBERATOR: { 102 | "AirCost": 10, 103 | "GroundCost": 0, 104 | "AirRange": 5, 105 | "GroundRange": 0, 106 | }, 107 | UnitTypeId.MARINE: { 108 | "AirCost": 10, 109 | "GroundCost": 10, 110 | "AirRange": 5, 111 | "GroundRange": 5, 112 | }, 113 | UnitTypeId.MOTHERSHIP: { 114 | "AirCost": 23, 115 | "GroundCost": 23, 116 | "AirRange": 7, 117 | "GroundRange": 7, 118 | }, 119 | UnitTypeId.MUTALISK: { 120 | "AirCost": 8, 121 | "GroundCost": 8, 122 | "AirRange": 3, 123 | "GroundRange": 3, 124 | }, 125 | UnitTypeId.ORACLE: { 126 | "AirCost": 0, 127 | "GroundCost": 24, 128 | "AirRange": 0, 129 | "GroundRange": 4, 130 | }, 131 | UnitTypeId.PHOENIX: { 132 | "AirCost": 15, 133 | "GroundCost": 0, 134 | "AirRange": 7, 135 | "GroundRange": 0, 136 | }, 137 | UnitTypeId.PHOTONCANNON: { 138 | "AirCost": 22, 139 | "GroundCost": 22, 140 | "AirRange": 7, 141 | "GroundRange": 7, 142 | }, 143 | UnitTypeId.QUEEN: { 144 | "AirCost": 12.6, 145 | "GroundCost": 11.2, 146 | "AirRange": 7, 147 | "GroundRange": 5, 148 | }, 149 | UnitTypeId.SENTRY: { 150 | "AirCost": 8.4, 151 | "GroundCost": 8.4, 152 | "AirRange": 5, 153 | "GroundRange": 5, 154 | }, 155 | UnitTypeId.SPINECRAWLER: { 156 | "AirCost": 0, 157 | "GroundCost": 15, 158 | "AirRange": 0, 159 | "GroundRange": 7, 160 | }, 161 | UnitTypeId.STALKER: { 162 | "AirCost": 10, 163 | "GroundCost": 10, 164 | "AirRange": 6, 165 | "GroundRange": 6, 166 | }, 167 | UnitTypeId.TEMPEST: { 168 | "AirCost": 17, 169 | "GroundCost": 17, 170 | "AirRange": 14, 171 | "GroundRange": 10, 172 | }, 173 | UnitTypeId.THOR: { 174 | "AirCost": 28, 175 | "GroundCost": 28, 176 | "AirRange": 11, 177 | "GroundRange": 7, 178 | }, 179 | UnitTypeId.VIKINGASSAULT: { 180 | "AirCost": 0, 181 | "GroundCost": 17, 182 | "AirRange": 0, 183 | "GroundRange": 6, 184 | }, 185 | UnitTypeId.VIKINGFIGHTER: { 186 | "AirCost": 14, 187 | "GroundCost": 0, 188 | "AirRange": 9, 189 | "GroundRange": 0, 190 | }, 191 | UnitTypeId.VOIDRAY: { 192 | "AirCost": 20, 193 | "GroundCost": 20, 194 | "AirRange": 6, 195 | "GroundRange": 6, 196 | }, 197 | UnitTypeId.WIDOWMINEBURROWED: { 198 | "AirCost": 150, 199 | "GroundCost": 150, 200 | "AirRange": 5.5, 201 | "GroundRange": 5.5, 202 | }, 203 | } 204 | -------------------------------------------------------------------------------- /examples/MassReaper/bot/main.py: -------------------------------------------------------------------------------- 1 | from typing import Optional, Set 2 | 3 | from bot.consts import ATTACK_TARGET_IGNORE 4 | from bot.pathing import Pathing 5 | from bot.reapers import Reapers 6 | from sc2.bot_ai import BotAI 7 | from sc2.ids.ability_id import AbilityId 8 | from sc2.ids.unit_typeid import UnitTypeId 9 | from sc2.position import Point2 10 | from sc2.unit import Unit 11 | from sc2.units import Units 12 | 13 | DEBUG: bool = False 14 | 15 | 16 | class MassReaper(BotAI): 17 | # In pathing we are going to use MapAnalyzer's pathing module 18 | # Here we will add enemy influence, and create pathing methods 19 | # Our reapers will have access to this class 20 | pathing: Pathing 21 | 22 | # use a separate class for all reaper control 23 | reapers: Reapers 24 | 25 | def __init__(self): 26 | super().__init__() 27 | self.worker_defence_tags: Set[int] = set() 28 | self.enemy_committed_worker_rush: bool = False 29 | 30 | async def on_before_start(self) -> None: 31 | for worker in self.workers: 32 | worker.gather(self.mineral_field.closest_to(worker)) 33 | 34 | async def on_start(self) -> None: 35 | self.client.game_step = 2 36 | self.pathing = Pathing(self, DEBUG) 37 | self.reapers = Reapers(self, self.pathing) 38 | 39 | async def on_step(self, iteration: int): 40 | # The macro part of this uses the reaper example in python-sc2: 41 | # https://github.com/BurnySc2/python-sc2/blob/develop/examples/terran/mass_reaper.py 42 | # this could be improved 43 | await self._do_mass_reaper_macro(iteration) 44 | 45 | # add enemy units to our pathing grids (influence) 46 | self.pathing.update() 47 | 48 | # make one call to get out attack target 49 | attack_target: Point2 = self.get_attack_target 50 | # call attack method in our reaper class 51 | await self.reapers.handle_attackers( 52 | self.units(UnitTypeId.REAPER), attack_target 53 | ) 54 | 55 | @property 56 | def get_attack_target(self) -> Point2: 57 | if self.time > 300.0: 58 | if enemy_units := self.enemy_units.filter( 59 | lambda u: u.type_id not in ATTACK_TARGET_IGNORE 60 | and not u.is_flying 61 | and not u.is_cloaked 62 | and not u.is_hallucination 63 | ): 64 | return enemy_units.closest_to(self.start_location).position 65 | elif enemy_structures := self.enemy_structures: 66 | return enemy_structures.closest_to(self.start_location).position 67 | 68 | return self.enemy_start_locations[0] 69 | 70 | async def _do_mass_reaper_macro(self, iteration: int): 71 | """ 72 | Stolen from 73 | https://github.com/BurnySc2/python-sc2/blob/develop/examples/terran/mass_reaper.py 74 | With a few small tweaks 75 | - build depots when low on remaining supply 76 | - townhalls contains commandcenter and orbitalcommand 77 | - self.units(TYPE).not_ready.amount selects all units of that type, 78 | filters incomplete units, and then counts the amount 79 | - self.already_pending(TYPE) counts how many units are queued 80 | """ 81 | if ( 82 | self.supply_left < 5 83 | and self.townhalls 84 | and self.supply_used >= 14 85 | and self.can_afford(UnitTypeId.SUPPLYDEPOT) 86 | and self.already_pending(UnitTypeId.SUPPLYDEPOT) < 1 87 | ): 88 | if workers := self.workers.gathering: 89 | worker: Unit = workers.furthest_to(workers.center) 90 | location: Optional[Point2] = await self.find_placement( 91 | UnitTypeId.SUPPLYDEPOT, worker.position, placement_step=3 92 | ) 93 | # If a placement location was found 94 | if location: 95 | # Order worker to build exactly on that location 96 | worker.build(UnitTypeId.SUPPLYDEPOT, location) 97 | 98 | # Lower all depots when finished 99 | for depot in self.structures(UnitTypeId.SUPPLYDEPOT).ready: 100 | depot(AbilityId.MORPH_SUPPLYDEPOT_LOWER) 101 | 102 | # Morph commandcenter to orbitalcommand 103 | # Check if tech requirement for orbital is complete (e.g. you need a 104 | # barracks to be able to morph an orbital) 105 | if self.tech_requirement_progress(UnitTypeId.ORBITALCOMMAND) == 1: 106 | # Loop over all idle command centers 107 | # (CCs that are not building SCVs or morphing to orbital) 108 | for cc in self.townhalls(UnitTypeId.COMMANDCENTER).idle: 109 | if self.can_afford(UnitTypeId.ORBITALCOMMAND): 110 | cc(AbilityId.UPGRADETOORBITAL_ORBITALCOMMAND) 111 | 112 | # Expand if we can afford (400 minerals) and have less than 2 bases 113 | if ( 114 | 1 <= self.townhalls.amount < 2 115 | and self.already_pending(UnitTypeId.COMMANDCENTER) == 0 116 | and self.can_afford(UnitTypeId.COMMANDCENTER) 117 | ): 118 | # get_next_expansion returns the position of the next 119 | # possible expansion location where you can place a command center 120 | location: Point2 = await self.get_next_expansion() 121 | if location: 122 | # Now we "select" (or choose) the nearest worker to that found location 123 | worker: Unit = self.select_build_worker(location) 124 | if worker and self.can_afford(UnitTypeId.COMMANDCENTER): 125 | # The worker will be commanded to build the command center 126 | worker.build(UnitTypeId.COMMANDCENTER, location) 127 | 128 | # Build up to 7 barracks if we can afford them 129 | max_barracks: int = 4 if len(self.townhalls) <= 1 else 7 130 | if ( 131 | self.tech_requirement_progress(UnitTypeId.BARRACKS) == 1 132 | and self.structures(UnitTypeId.BARRACKS).ready.amount 133 | + self.already_pending(UnitTypeId.BARRACKS) 134 | < max_barracks 135 | and self.can_afford(UnitTypeId.BARRACKS) 136 | ): 137 | workers: Units = self.workers.gathering 138 | if ( 139 | workers and self.townhalls 140 | ): # need to check if townhalls.amount > 0 because placement is 141 | # based on townhall location 142 | worker: Unit = workers.furthest_to(workers.center) 143 | # I chose placement_step 4 here so there 144 | # will be gaps between barracks hopefully 145 | location: Point2 = await self.find_placement( 146 | UnitTypeId.BARRACKS, 147 | self.townhalls.random.position.towards( 148 | self.game_info.map_center, 8 149 | ), 150 | placement_step=4, 151 | ) 152 | if location: 153 | worker.build(UnitTypeId.BARRACKS, location) 154 | 155 | # Build refineries (on nearby vespene) when at least 156 | # one barracks is in construction 157 | if ( 158 | self.structures(UnitTypeId.BARRACKS).ready.amount 159 | + self.already_pending(UnitTypeId.BARRACKS) 160 | > 0 161 | and self.already_pending(UnitTypeId.REFINERY) < 1 162 | ): 163 | # Loop over all townhalls nearly complete 164 | for th in self.townhalls.filter(lambda _th: _th.build_progress > 0.3): 165 | # Find all vespene geysers that are closer 166 | # than range 10 to this townhall 167 | vgs: Units = self.vespene_geyser.closer_than(10, th) 168 | for vg in vgs: 169 | if await self.can_place_single( 170 | UnitTypeId.REFINERY, vg.position 171 | ) and self.can_afford(UnitTypeId.REFINERY): 172 | workers: Units = self.workers.gathering 173 | if workers: # same condition as above 174 | worker: Unit = workers.closest_to(vg) 175 | # Caution: the target for the refinery has to be the 176 | # vespene geyser, not its position! 177 | worker.build_gas(vg) 178 | 179 | # Dont build more than one each frame 180 | break 181 | 182 | # Make scvs until 22 183 | if ( 184 | self.can_afford(UnitTypeId.SCV) 185 | and self.supply_left > 0 186 | and self.supply_workers < 22 187 | and ( 188 | self.structures(UnitTypeId.BARRACKS).ready.amount < 1 189 | and self.townhalls(UnitTypeId.COMMANDCENTER).idle 190 | or self.townhalls(UnitTypeId.ORBITALCOMMAND).idle 191 | ) 192 | ): 193 | for th in self.townhalls.idle: 194 | th.train(UnitTypeId.SCV) 195 | 196 | # Make reapers if we can afford them and we have supply remaining 197 | if self.supply_left > 0: 198 | # Loop through all idle barracks 199 | for rax in self.structures(UnitTypeId.BARRACKS).idle: 200 | if self.can_afford(UnitTypeId.REAPER): 201 | rax.train(UnitTypeId.REAPER) 202 | 203 | # Send workers to mine 204 | if iteration % 12 == 0: 205 | await self.my_distribute_workers() 206 | 207 | # Manage orbital energy and drop mules 208 | for oc in self.townhalls(UnitTypeId.ORBITALCOMMAND).filter( 209 | lambda x: x.energy >= 50 210 | ): 211 | mfs: Units = self.mineral_field.closer_than(10, oc) 212 | if mfs: 213 | mf: Unit = max(mfs, key=lambda x: x.mineral_contents) 214 | oc(AbilityId.CALLDOWNMULE_CALLDOWNMULE, mf) 215 | 216 | # Distribute workers function rewritten, the default distribute_workers() 217 | # function did not saturate gas quickly enough 218 | # pylint: disable=R0912 219 | async def my_distribute_workers( 220 | self, performance_heavy=True, only_saturate_gas=False 221 | ): 222 | mineral_tags = [x.tag for x in self.mineral_field] 223 | gas_building_tags = [x.tag for x in self.gas_buildings] 224 | 225 | worker_pool = self.workers.idle 226 | worker_pool_tags = worker_pool.tags 227 | 228 | # Find all gas_buildings that have surplus or deficit 229 | deficit_gas_buildings = {} 230 | surplusgas_buildings = {} 231 | for g in self.gas_buildings.filter(lambda x: x.vespene_contents > 0): 232 | # Only loop over gas_buildings that have still gas in them 233 | deficit = g.ideal_harvesters - g.assigned_harvesters 234 | if deficit > 0: 235 | deficit_gas_buildings[g.tag] = {"unit": g, "deficit": deficit} 236 | elif deficit < 0: 237 | surplus_workers = self.workers.closer_than(10, g).filter( 238 | lambda w: w not in worker_pool_tags 239 | and len(w.orders) == 1 240 | and w.orders[0].ability.id in [AbilityId.HARVEST_GATHER] 241 | and w.orders[0].target in gas_building_tags 242 | ) 243 | for _ in range(-deficit): 244 | if surplus_workers.amount > 0: 245 | w = surplus_workers.pop() 246 | worker_pool.append(w) 247 | worker_pool_tags.add(w.tag) 248 | surplusgas_buildings[g.tag] = {"unit": g, "deficit": deficit} 249 | 250 | # Find all townhalls that have surplus or deficit 251 | deficit_townhalls = {} 252 | surplus_townhalls = {} 253 | if not only_saturate_gas: 254 | for th in self.townhalls: 255 | deficit = th.ideal_harvesters - th.assigned_harvesters 256 | if deficit > 0: 257 | deficit_townhalls[th.tag] = {"unit": th, "deficit": deficit} 258 | elif deficit < 0: 259 | surplus_workers = self.workers.closer_than(10, th).filter( 260 | lambda w: w.tag not in worker_pool_tags 261 | and len(w.orders) == 1 262 | and w.orders[0].ability.id in [AbilityId.HARVEST_GATHER] 263 | and w.orders[0].target in mineral_tags 264 | ) 265 | # worker_pool.extend(surplus_workers) 266 | for _ in range(-deficit): 267 | if surplus_workers.amount > 0: 268 | w = surplus_workers.pop() 269 | worker_pool.append(w) 270 | worker_pool_tags.add(w.tag) 271 | surplus_townhalls[th.tag] = {"unit": th, "deficit": deficit} 272 | 273 | # Check if deficit in gas less or equal than what we have in 274 | # surplus, else grab some more workers from surplus bases 275 | deficit_gas_count = sum( 276 | gasInfo["deficit"] 277 | for gasTag, gasInfo in deficit_gas_buildings.items() 278 | if gasInfo["deficit"] > 0 279 | ) 280 | surplus_count = sum( 281 | -gasInfo["deficit"] 282 | for gasTag, gasInfo in surplusgas_buildings.items() 283 | if gasInfo["deficit"] < 0 284 | ) 285 | surplus_count += sum( 286 | -townhall_info["deficit"] 287 | for townhall_tag, townhall_info in surplus_townhalls.items() 288 | if townhall_info["deficit"] < 0 289 | ) 290 | 291 | if deficit_gas_count - surplus_count > 0: 292 | # Grab workers near the gas who are mining minerals 293 | for _gas_tag, gas_info in deficit_gas_buildings.items(): 294 | if worker_pool.amount >= deficit_gas_count: 295 | break 296 | workers_near_gas = self.workers.closer_than( 297 | 50, gas_info["unit"] 298 | ).filter( 299 | lambda w: w.tag not in worker_pool_tags 300 | and len(w.orders) == 1 301 | and w.orders[0].ability.id in [AbilityId.HARVEST_GATHER] 302 | and w.orders[0].target in mineral_tags 303 | ) 304 | while ( 305 | workers_near_gas.amount > 0 306 | and worker_pool.amount < deficit_gas_count 307 | ): 308 | w = workers_near_gas.pop() 309 | worker_pool.append(w) 310 | worker_pool_tags.add(w.tag) 311 | 312 | # Now we should have enough workers in the pool to saturate all gases, 313 | # and if there are workers left over, make them mine at townhalls 314 | # that have mineral workers deficit 315 | for _gas_tag, gas_info in deficit_gas_buildings.items(): 316 | if performance_heavy: 317 | # Sort the furthest away to closest 318 | # (as the pop() function will take the last element) 319 | worker_pool.sort( 320 | key=lambda x: x.distance_to(gas_info["unit"]), reverse=True 321 | ) 322 | for _ in range(gas_info["deficit"]): 323 | if worker_pool.amount > 0: 324 | w = worker_pool.pop() 325 | if len(w.orders) == 1 and w.orders[0].ability.id in [ 326 | AbilityId.HARVEST_RETURN 327 | ]: 328 | w.gather(gas_info["unit"], queue=True) 329 | else: 330 | w.gather(gas_info["unit"]) 331 | 332 | if not only_saturate_gas: 333 | # If we now have left over workers, 334 | # make them mine at bases with deficit in mineral workers 335 | for townhall_tag, townhall_info in deficit_townhalls.items(): 336 | if performance_heavy: 337 | # Sort furthest away to closest 338 | # (as the pop() function will take the last element) 339 | worker_pool.sort( 340 | key=lambda x: x.distance_to(townhall_info["unit"]), reverse=True 341 | ) 342 | for _ in range(townhall_info["deficit"]): 343 | if worker_pool.amount > 0: 344 | w = worker_pool.pop() 345 | mf = self.mineral_field.closer_than( 346 | 10, townhall_info["unit"] 347 | ).closest_to(w) 348 | if len(w.orders) == 1 and w.orders[0].ability.id in [ 349 | AbilityId.HARVEST_RETURN 350 | ]: 351 | w.gather(mf, queue=True) 352 | else: 353 | w.gather(mf) 354 | -------------------------------------------------------------------------------- /examples/MassReaper/bot/pathing.py: -------------------------------------------------------------------------------- 1 | """ 2 | This provides a wrapper for the MapAnalyzer library 3 | Here we are only using the Pathing module in MapAnalyzer, 4 | there are more features to explore! 5 | We add enemy influence to the pathing grids 6 | And implement pathing methods our units can use 7 | """ 8 | 9 | from typing import Dict, List, Optional 10 | 11 | import numpy as np 12 | from bot.consts import ALL_STRUCTURES 13 | from bot.influence_costs import INFLUENCE_COSTS 14 | from sc2.bot_ai import BotAI 15 | from sc2.position import Point2 16 | from sc2.unit import Unit 17 | from scipy import spatial 18 | 19 | from map_analyzer import MapData 20 | 21 | # When adding enemies to the grids add a bit extra range 22 | # so our units stay out of trouble 23 | RANGE_BUFFER: float = 3.0 24 | 25 | 26 | class Pathing: 27 | def __init__(self, ai: BotAI, debug: bool) -> None: 28 | self.ai: BotAI = ai 29 | self.debug: bool = debug 30 | 31 | # initialize MapAnalyzer library 32 | self.map_data: MapData = MapData(ai) 33 | 34 | # we will need fresh grids every step, to update the enemy positions 35 | 36 | # for reapers / colossus we need a special grid to use the cliffs 37 | self.reaper_grid: np.ndarray = self.map_data.get_climber_grid() 38 | 39 | # ground grid not actually used in this example, 40 | # but is setup ready to go for other ground units 41 | self.ground_grid: np.ndarray = self.map_data.get_pyastar_grid() 42 | 43 | # air grid if needed, would need to add enemy influence 44 | # self.air_grid: np.ndarray = self.map_data.get_clean_air_grid() 45 | 46 | def update(self) -> None: 47 | self.ground_grid = self.map_data.get_pyastar_grid() 48 | self.reaper_grid = self.map_data.get_climber_grid() 49 | for unit in self.ai.all_enemy_units: 50 | # checking if a unit is a structure this way is 51 | # faster than using `if unit.is_structure` :) 52 | if unit.type_id in ALL_STRUCTURES: 53 | self._add_structure_influence(unit) 54 | else: 55 | self._add_unit_influence(unit) 56 | 57 | # TODO: Add effect influence like storm, ravager biles, nukes etc 58 | # `for effect in self.ai.state.effects: ...` 59 | 60 | if self.debug: 61 | self.map_data.draw_influence_in_game(self.reaper_grid, lower_threshold=1) 62 | 63 | def find_closest_safe_spot( 64 | self, from_pos: Point2, grid: np.ndarray, radius: int = 15 65 | ) -> Point2: 66 | """ 67 | @param from_pos: 68 | @param grid: 69 | @param radius: 70 | @return: 71 | """ 72 | all_safe: np.ndarray = self.map_data.lowest_cost_points_array( 73 | from_pos, radius, grid 74 | ) 75 | # type hint wants a numpy array but doesn't actually need one - this is faster 76 | all_dists = spatial.distance.cdist(all_safe, [from_pos], "sqeuclidean") 77 | min_index = np.argmin(all_dists) 78 | 79 | # safe because the shape of all_dists (N x 1) means argmin will return an int 80 | return Point2(all_safe[min_index]) 81 | 82 | def find_path_next_point( 83 | self, 84 | start: Point2, 85 | target: Point2, 86 | grid: np.ndarray, 87 | sensitivity: int = 2, 88 | smoothing: bool = False, 89 | ) -> Point2: 90 | """ 91 | Most commonly used, we need to calculate the right path for a unit 92 | But only the first element of the path is required 93 | @param start: 94 | @param target: 95 | @param grid: 96 | @param sensitivity: 97 | @param smoothing: 98 | @return: The next point on the path we should move to 99 | """ 100 | # Note: On rare occasions a path is not found and returns `None` 101 | path: Optional[List[Point2]] = self.map_data.pathfind( 102 | start, target, grid, sensitivity=sensitivity, smoothing=smoothing 103 | ) 104 | if not path or len(path) == 0: 105 | return target 106 | else: 107 | return path[0] 108 | 109 | @staticmethod 110 | def is_position_safe( 111 | grid: np.ndarray, 112 | position: Point2, 113 | weight_safety_limit: float = 1.0, 114 | ) -> bool: 115 | """ 116 | Checks if the current position is dangerous by 117 | comparing against default_grid_weights 118 | @param grid: Grid we want to check 119 | @param position: Position of the unit etc 120 | @param weight_safety_limit: The threshold at which we declare the position safe 121 | @return: 122 | """ 123 | position = position.rounded 124 | weight: float = grid[position.x, position.y] 125 | # np.inf check if drone is pathing near a spore crawler 126 | return weight == np.inf or weight <= weight_safety_limit 127 | 128 | def _add_unit_influence(self, enemy: Unit) -> None: 129 | """ 130 | Add influence to the relevant grid. 131 | TODO: 132 | Add spell castors 133 | Add units that have no weapon in the API such as BCs, sentries and voids 134 | Extend this to add influence to an air grid 135 | @return: 136 | """ 137 | # this unit is in our dictionary where we define custom weights and ranges 138 | # it could be this unit doesn't have a weapon in the API 139 | # or we just want to use custom values 140 | if enemy.type_id in INFLUENCE_COSTS: 141 | values: Dict = INFLUENCE_COSTS[enemy.type_id] 142 | (self.ground_grid, self.reaper_grid) = self._add_cost_to_multiple_grids( 143 | enemy.position, 144 | values["GroundCost"], 145 | values["GroundRange"] + RANGE_BUFFER, 146 | [self.ground_grid, self.reaper_grid], 147 | ) 148 | # this unit has values in the API and is not in our custom dictionary, 149 | # take them from there 150 | elif enemy.can_attack_ground: 151 | (self.ground_grid, self.reaper_grid) = self._add_cost_to_multiple_grids( 152 | enemy.position, 153 | enemy.ground_dps, 154 | enemy.ground_range + RANGE_BUFFER, 155 | [self.ground_grid, self.reaper_grid], 156 | ) 157 | 158 | def _add_structure_influence(self, enemy: Unit) -> None: 159 | """ 160 | Add structure influence to the relevant grid. 161 | TODO: 162 | Extend this to add influence to an air grid 163 | @param enemy: 164 | @return: 165 | """ 166 | if not enemy.is_ready: 167 | return 168 | 169 | if enemy.type_id in INFLUENCE_COSTS: 170 | values: Dict = INFLUENCE_COSTS[enemy.type_id] 171 | (self.ground_grid, self.reaper_grid) = self._add_cost_to_multiple_grids( 172 | enemy.position, 173 | values["GroundCost"], 174 | values["GroundRange"] + RANGE_BUFFER, 175 | [self.ground_grid, self.reaper_grid], 176 | ) 177 | 178 | def _add_cost( 179 | self, 180 | pos: Point2, 181 | weight: float, 182 | unit_range: float, 183 | grid: np.ndarray, 184 | initial_default_weights: int = 0, 185 | ) -> np.ndarray: 186 | """Or add "influence", mostly used to add enemies to a grid""" 187 | 188 | grid = self.map_data.add_cost( 189 | position=(int(pos.x), int(pos.y)), 190 | radius=unit_range, 191 | grid=grid, 192 | weight=int(weight), 193 | initial_default_weights=initial_default_weights, 194 | ) 195 | return grid 196 | 197 | def _add_cost_to_multiple_grids( 198 | self, 199 | pos: Point2, 200 | weight: float, 201 | unit_range: float, 202 | grids: List[np.ndarray], 203 | initial_default_weights: int = 0, 204 | ) -> List[np.ndarray]: 205 | """ 206 | Similar to method above, but add cost to multiple grids at once 207 | This is much faster then doing it one at a time 208 | """ 209 | 210 | grids = self.map_data.add_cost_to_multiple_grids( 211 | position=(int(pos.x), int(pos.y)), 212 | radius=unit_range, 213 | grids=grids, 214 | weight=int(weight), 215 | initial_default_weights=initial_default_weights, 216 | ) 217 | return grids 218 | -------------------------------------------------------------------------------- /examples/MassReaper/bot/reapers.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | import numpy as np 4 | from bot.consts import ALL_STRUCTURES, ATTACK_TARGET_IGNORE 5 | from bot.pathing import Pathing 6 | from sc2.bot_ai import BotAI 7 | from sc2.ids.ability_id import AbilityId 8 | from sc2.position import Point2 9 | from sc2.unit import Unit 10 | from sc2.units import Units 11 | 12 | HEAL_AT_LESS_THAN: float = 0.5 13 | 14 | 15 | class Reapers: 16 | def __init__(self, ai: BotAI, pathing: Pathing): 17 | self.ai: BotAI = ai 18 | self.pathing: Pathing = pathing 19 | 20 | self.reaper_grenade_range: float = self.ai.game_data.abilities[ 21 | AbilityId.KD8CHARGE_KD8CHARGE.value 22 | ]._proto.cast_range 23 | 24 | @property 25 | def get_heal_spot(self) -> Point2: 26 | return self.pathing.find_closest_safe_spot( 27 | self.ai.game_info.map_center, self.pathing.reaper_grid 28 | ) 29 | 30 | async def handle_attackers(self, units: Units, attack_target: Point2) -> None: 31 | grid: np.ndarray = self.pathing.reaper_grid 32 | for unit in units: 33 | # pull back low health reapers to heal 34 | if unit.health_percentage < HEAL_AT_LESS_THAN: 35 | unit.move( 36 | self.pathing.find_path_next_point( 37 | unit.position, self.get_heal_spot, grid 38 | ) 39 | ) 40 | continue 41 | 42 | close_enemies: Units = self.ai.enemy_units.filter( 43 | lambda u: u.position.distance_to(unit) < 15.0 44 | and not u.is_flying 45 | and unit.type_id not in ATTACK_TARGET_IGNORE 46 | ) 47 | 48 | # reaper grenade 49 | if await self._do_reaper_grenade(unit, close_enemies): 50 | continue 51 | 52 | # check for nearby target fire 53 | target: Optional[Unit] = None 54 | if close_enemies: 55 | in_attack_range: Units = close_enemies.in_attack_range_of(unit) 56 | if in_attack_range: 57 | target = self.pick_enemy_target(in_attack_range) 58 | else: 59 | target = self.pick_enemy_target(close_enemies) 60 | 61 | if target and unit.weapon_cooldown == 0: 62 | unit.attack(target) 63 | continue 64 | 65 | # no target and in danger, run away 66 | if not self.pathing.is_position_safe(grid, unit.position): 67 | self.move_to_safety(unit, grid) 68 | continue 69 | 70 | # get to the target 71 | if unit.distance_to(attack_target) > 5: 72 | # only make pathing queries if enemies are close 73 | if close_enemies: 74 | unit.move( 75 | self.pathing.find_path_next_point( 76 | unit.position, attack_target, grid 77 | ) 78 | ) 79 | else: 80 | unit.move(attack_target) 81 | else: 82 | unit.attack(attack_target) 83 | 84 | def move_to_safety(self, unit: Unit, grid: np.ndarray): 85 | """ 86 | Find a close safe spot on our grid 87 | Then path to it 88 | """ 89 | safe_spot: Point2 = self.pathing.find_closest_safe_spot(unit.position, grid) 90 | move_to: Point2 = self.pathing.find_path_next_point( 91 | unit.position, safe_spot, grid 92 | ) 93 | unit.move(move_to) 94 | 95 | @staticmethod 96 | def pick_enemy_target(enemies: Units) -> Unit: 97 | """For best enemy target from the provided enemies 98 | TODO: If there are multiple units that can be killed in one shot, 99 | pick the highest value one 100 | """ 101 | return min( 102 | enemies, 103 | key=lambda e: (e.health + e.shield, e.tag), 104 | ) 105 | 106 | async def _do_reaper_grenade(self, r: Unit, close_enemies: Units) -> bool: 107 | """ 108 | Taken from burny's example 109 | https://github.com/BurnySc2/python-sc2/blob/develop/examples/terran/mass_reaper.py 110 | """ 111 | enemy_ground_units_in_grenade_range: Units = close_enemies.filter( 112 | lambda unit: unit.type_id not in ALL_STRUCTURES 113 | and unit.type_id not in ATTACK_TARGET_IGNORE 114 | and unit.distance_to(unit) < self.reaper_grenade_range 115 | ) 116 | 117 | if enemy_ground_units_in_grenade_range and (r.is_attacking or r.is_moving): 118 | # If AbilityId.KD8CHARGE_KD8CHARGE in abilities, 119 | # we check that to see if the reaper grenade is off cooldown 120 | abilities = await self.ai.get_available_abilities(r) 121 | enemy_ground_units_in_grenade_range = ( 122 | enemy_ground_units_in_grenade_range.sorted( 123 | lambda x: x.distance_to(r), reverse=True 124 | ) 125 | ) 126 | furthest_enemy: Unit = None 127 | for enemy in enemy_ground_units_in_grenade_range: 128 | if await self.ai.can_cast( 129 | r, 130 | AbilityId.KD8CHARGE_KD8CHARGE, 131 | enemy, 132 | cached_abilities_of_unit=abilities, 133 | ): 134 | furthest_enemy: Unit = enemy 135 | break 136 | if furthest_enemy: 137 | r(AbilityId.KD8CHARGE_KD8CHARGE, furthest_enemy) 138 | return True 139 | 140 | return False 141 | -------------------------------------------------------------------------------- /examples/MassReaper/ladder.py: -------------------------------------------------------------------------------- 1 | # Run ladder game 2 | # This lets python-sc2 connect to a LadderManager game: 3 | # https://github.com/Cryptyc/Sc2LadderServer 4 | # Based on: 5 | # https://github.com/Dentosal/python-sc2/blob/master/examples/run_external.py 6 | import argparse 7 | import asyncio 8 | import logging 9 | 10 | import aiohttp 11 | import sc2 12 | from sc2.client import Client 13 | from sc2.protocol import ConnectionAlreadyClosed 14 | 15 | 16 | def run_ladder_game(bot): 17 | # Load command line arguments 18 | parser = argparse.ArgumentParser() 19 | parser.add_argument("--GamePort", type=int, nargs="?", help="Game port") 20 | parser.add_argument("--StartPort", type=int, nargs="?", help="Start port") 21 | parser.add_argument("--LadderServer", type=str, nargs="?", help="Ladder server") 22 | parser.add_argument( 23 | "--ComputerOpponent", type=str, nargs="?", help="Computer opponent" 24 | ) 25 | parser.add_argument("--ComputerRace", type=str, nargs="?", help="Computer race") 26 | parser.add_argument( 27 | "--ComputerDifficulty", type=str, nargs="?", help="Computer difficulty" 28 | ) 29 | parser.add_argument("--OpponentId", type=str, nargs="?", help="Opponent ID") 30 | parser.add_argument("--RealTime", action="store_true", help="real time flag") 31 | args, unknown = parser.parse_known_args() 32 | 33 | if args.LadderServer is None: 34 | host = "127.0.0.1" 35 | else: 36 | host = args.LadderServer 37 | 38 | host_port = args.GamePort 39 | lan_port = args.StartPort 40 | 41 | # Add opponent_id to the bot class (accessed through self.opponent_id) 42 | bot.ai.opponent_id = args.OpponentId 43 | 44 | # Port config 45 | ports = [lan_port + p for p in range(1, 6)] 46 | 47 | portconfig = sc2.portconfig.Portconfig() 48 | portconfig.shared = ports[0] # Not used 49 | portconfig.server = [ports[1], ports[2]] 50 | portconfig.players = [[ports[3], ports[4]]] 51 | 52 | # Join ladder game 53 | g = join_ladder_game( 54 | host=host, 55 | port=host_port, 56 | players=[bot], 57 | realtime=args.RealTime, 58 | portconfig=portconfig, 59 | ) 60 | 61 | # Run it 62 | result = asyncio.get_event_loop().run_until_complete(g) 63 | return result, args.OpponentId 64 | 65 | 66 | # Modified version of sc2.main._join_game to allow custom host and port, 67 | # and to not spawn an additional sc2process (thanks to alkurbatov for fix) 68 | async def join_ladder_game( 69 | host, 70 | port, 71 | players, 72 | realtime, 73 | portconfig, 74 | save_replay_as=None, 75 | step_time_limit=None, 76 | game_time_limit=None, 77 | ): 78 | ws_url = f"ws://{host}:{port}/sc2api" 79 | ws_connection = await aiohttp.ClientSession().ws_connect(ws_url, timeout=120) 80 | 81 | client = Client(ws_connection) 82 | try: 83 | result = await sc2.main._play_game( 84 | players[0], client, realtime, portconfig, step_time_limit, game_time_limit 85 | ) 86 | if save_replay_as is not None: 87 | await client.save_replay(save_replay_as) 88 | except ConnectionAlreadyClosed: 89 | logging.error("Connection was closed before the game ended") 90 | return None 91 | finally: 92 | await ws_connection.close() 93 | 94 | return result 95 | -------------------------------------------------------------------------------- /examples/MassReaper/run.py: -------------------------------------------------------------------------------- 1 | import random 2 | import sys 3 | 4 | from bot.main import MassReaper 5 | from ladder import run_ladder_game 6 | from sc2 import maps 7 | from sc2.data import Difficulty, Race 8 | from sc2.main import run_game 9 | from sc2.player import AIBuild, Bot, Computer 10 | 11 | bot1 = Bot(Race.Terran, MassReaper()) 12 | 13 | 14 | def main(): 15 | # Ladder game started by LadderManager 16 | print("Starting ladder game...") 17 | result, opponentid = run_ladder_game(bot1) 18 | print(result, " against opponent ", opponentid) 19 | 20 | 21 | # Start game 22 | if __name__ == "__main__": 23 | if "--LadderServer" in sys.argv: 24 | # Ladder game started by LadderManager 25 | print("Starting ladder game...") 26 | result, opponentid = run_ladder_game(bot1) 27 | print(result, " against opponent ", opponentid) 28 | else: 29 | # Local game 30 | random_map = random.choice( 31 | [ 32 | "2000AtmospheresAIE", 33 | "BerlingradAIE", 34 | "BlackburnAIE", 35 | "CuriousMindsAIE", 36 | "GlitteringAshesAIE", 37 | "HardwireAIE", 38 | ] 39 | ) 40 | random_race = random.choice( 41 | [ 42 | Race.Zerg, 43 | Race.Terran, 44 | Race.Protoss, 45 | ] 46 | ) 47 | print("Starting local game...") 48 | 49 | run_game( 50 | maps.get(random_map), 51 | [ 52 | bot1, 53 | Computer(random_race, Difficulty.CheatVision, ai_build=AIBuild.Macro), 54 | ], 55 | realtime=False, 56 | ) 57 | -------------------------------------------------------------------------------- /ladder_build.sh: -------------------------------------------------------------------------------- 1 | docker build . -f ./ladder_build/Dockerfile -t maextbuild 2 | id=$(docker create maextbuild) 3 | docker cp $id:./mapanalyzerext.so ./map_analyzer/cext/mapanalyzerext.so 4 | docker rm -v $id 5 | 6 | -------------------------------------------------------------------------------- /ladder_build/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.11.5 2 | 3 | COPY . . 4 | 5 | RUN pip install poetry==1.6.1 6 | RUN poetry install 7 | RUN mv build/lib.linux*/mapanalyzerext.*.so mapanalyzerext.so 8 | 9 | CMD ["/bin/bash"] 10 | -------------------------------------------------------------------------------- /map_analyzer/Debugger.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | import os 3 | import sys 4 | import warnings 5 | from typing import TYPE_CHECKING, Any, Dict, Optional, Tuple, Union 6 | 7 | import numpy as np 8 | from loguru import logger 9 | from numpy import ndarray 10 | from sc2.bot_ai import BotAI 11 | from sc2.position import Point2, Point3 12 | 13 | from .constants import COLORS, LOG_FORMAT, LOG_MODULE 14 | 15 | if TYPE_CHECKING: 16 | from map_analyzer.MapData import MapData 17 | 18 | 19 | class LocalLogFilter: 20 | def __init__(self, module_name: str, level: str = "ERROR") -> None: 21 | self.module_name = module_name 22 | self.level = level 23 | 24 | def __call__(self, record: Dict[str, Any]) -> bool: 25 | levelno = logger.level(self.level).no 26 | if self.module_name.lower() in record["name"].lower(): 27 | return record["level"].no >= levelno 28 | return False 29 | 30 | 31 | class LogFilter: 32 | def __init__(self, level: str = "ERROR") -> None: 33 | self.level = level 34 | 35 | def __call__(self, record: Dict[str, Any]) -> bool: 36 | levelno = logger.level(self.level).no 37 | if "sc2." not in record["name"].lower(): 38 | return record["level"].no >= levelno 39 | return False 40 | 41 | 42 | class MapAnalyzerDebugger: 43 | """ 44 | MapAnalyzerDebugger 45 | """ 46 | 47 | def __init__(self, map_data: "MapData", loglevel: str = "ERROR") -> None: 48 | self.map_data = map_data 49 | self.warnings = warnings 50 | self.warnings.filterwarnings("ignore", category=DeprecationWarning) 51 | self.warnings.filterwarnings("ignore", category=RuntimeWarning) 52 | self.local_log_filter = LocalLogFilter(module_name=LOG_MODULE, level=loglevel) 53 | self.log_format = LOG_FORMAT 54 | self.log_filter = LogFilter(level=loglevel) 55 | logger.add(sys.stderr, format=self.log_format, filter=self.log_filter) 56 | 57 | @staticmethod 58 | def scatter(*args, **kwargs): 59 | import matplotlib.pyplot as plt 60 | 61 | plt.scatter(*args, **kwargs) 62 | 63 | @staticmethod 64 | def show(): 65 | import matplotlib.pyplot as plt 66 | 67 | plt.show() 68 | 69 | @staticmethod 70 | def close(): 71 | import matplotlib.pyplot as plt 72 | 73 | plt.close(fig="all") 74 | 75 | @staticmethod 76 | def save(filename: str) -> bool: 77 | for i in inspect.stack(): 78 | if "test_suite.py" in str(i): 79 | logger.info("Skipping save operation on test runs") 80 | logger.debug(f"index = {inspect.stack().index(i)} {i}") 81 | return True 82 | import matplotlib.pyplot as plt 83 | 84 | full_path = os.path.join(os.path.abspath("."), f"{filename}") 85 | plt.savefig(f"{filename}.png") 86 | logger.debug(f"Plot Saved to {full_path}") 87 | 88 | def plot_regions(self, fontdict: Dict[str, Union[str, int]]) -> None: 89 | """""" 90 | import matplotlib.pyplot as plt 91 | 92 | for lbl, reg in self.map_data.regions.items(): 93 | c = COLORS[lbl] 94 | fontdict["color"] = "black" 95 | fontdict["backgroundcolor"] = "black" 96 | # if c == 'black': 97 | # fontdict["backgroundcolor"] = 'white' 98 | plt.text( 99 | reg.center[0], 100 | reg.center[1], 101 | reg.label, 102 | bbox=dict( 103 | fill=True, 104 | alpha=0.9, 105 | edgecolor=fontdict["backgroundcolor"], 106 | linewidth=2, 107 | ), 108 | fontdict=fontdict, 109 | ) 110 | # random color for each perimeter 111 | x, y = zip(*reg.perimeter_points) 112 | plt.scatter(x, y, c=c, marker="1", s=300) 113 | for corner in reg.corner_points: 114 | plt.scatter(corner[0], corner[1], marker="v", c="red", s=150) 115 | 116 | def plot_vision_blockers(self) -> None: 117 | """ 118 | plot vbs 119 | """ 120 | import matplotlib.pyplot as plt 121 | 122 | for vb in self.map_data.vision_blockers: 123 | plt.text(vb[0], vb[1], "X") 124 | 125 | x, y = zip(*self.map_data.vision_blockers) 126 | plt.scatter(x, y, color="r") 127 | 128 | def plot_normal_resources(self) -> None: 129 | """ 130 | # todo: account for gold minerals and rich gas 131 | """ 132 | import matplotlib.pyplot as plt 133 | 134 | for mfield in self.map_data.mineral_fields: 135 | plt.scatter(mfield.position[0], mfield.position[1], color="blue") 136 | for gasgeyser in self.map_data.normal_geysers: 137 | plt.scatter( 138 | gasgeyser.position[0], 139 | gasgeyser.position[1], 140 | color="yellow", 141 | marker=r"$\spadesuit$", 142 | s=500, 143 | edgecolors="g", 144 | ) 145 | 146 | def plot_chokes(self) -> None: 147 | """ 148 | compute Chokes 149 | """ 150 | import matplotlib.pyplot as plt 151 | 152 | for choke in self.map_data.map_chokes: 153 | x, y = zip(*choke.points) 154 | cm = choke.center 155 | if choke.is_ramp: 156 | fontdict = {"family": "serif", "weight": "bold", "size": 15} 157 | plt.text( 158 | cm[0], 159 | cm[1], 160 | f"R<{[r.label for r in choke.regions]}>", 161 | fontdict=fontdict, 162 | bbox=dict(fill=True, alpha=0.4, edgecolor="cyan", linewidth=8), 163 | ) 164 | plt.scatter(x, y, color="w") 165 | elif choke.is_vision_blocker: 166 | fontdict = {"family": "serif", "size": 10} 167 | plt.text( 168 | cm[0], 169 | cm[1], 170 | "VB<>", 171 | fontdict=fontdict, 172 | bbox=dict(fill=True, alpha=0.3, edgecolor="red", linewidth=2), 173 | ) 174 | plt.scatter( 175 | x, y, marker=r"$\heartsuit$", s=100, edgecolors="b", alpha=0.3 176 | ) 177 | 178 | else: 179 | fontdict = {"family": "serif", "size": 10} 180 | plt.text( 181 | cm[0], 182 | cm[1], 183 | f"C<{choke.id}>", 184 | fontdict=fontdict, 185 | bbox=dict(fill=True, alpha=0.3, edgecolor="red", linewidth=2), 186 | ) 187 | plt.scatter( 188 | x, y, marker=r"$\heartsuit$", s=100, edgecolors="r", alpha=0.3 189 | ) 190 | walls = [choke.side_a, choke.side_b] 191 | x, y = zip(*walls) 192 | fontdict = {"family": "serif", "size": 5} 193 | if "unregistered" not in str(choke.id).lower(): 194 | plt.text( 195 | choke.side_a[0], 196 | choke.side_a[1], 197 | f"C<{choke.id}sA>", 198 | fontdict=fontdict, 199 | bbox=dict(fill=True, alpha=0.5, edgecolor="green", linewidth=2), 200 | ) 201 | plt.text( 202 | choke.side_b[0], 203 | choke.side_b[1], 204 | f"C<{choke.id}sB>", 205 | fontdict=fontdict, 206 | bbox=dict(fill=True, alpha=0.5, edgecolor="red", linewidth=2), 207 | ) 208 | else: 209 | plt.text( 210 | choke.side_a[0], 211 | choke.side_a[1], 212 | "sA>", 213 | fontdict=fontdict, 214 | bbox=dict(fill=True, alpha=0.5, edgecolor="green", linewidth=2), 215 | ) 216 | plt.text( 217 | choke.side_b[0], 218 | choke.side_b[1], 219 | "sB>", 220 | fontdict=fontdict, 221 | bbox=dict(fill=True, alpha=0.5, edgecolor="red", linewidth=2), 222 | ) 223 | plt.scatter(x, y, marker=r"$\spadesuit$", s=50, edgecolors="b", alpha=0.5) 224 | 225 | def plot_overlord_spots(self): 226 | import matplotlib.pyplot as plt 227 | 228 | for spot in self.map_data.overlord_spots: 229 | plt.scatter(spot[0], spot[1], marker="X", color="black") 230 | 231 | def plot_map(self, fontdict: dict = None, figsize: int = 20) -> None: 232 | """ 233 | 234 | Plot map 235 | 236 | """ 237 | 238 | if not fontdict: 239 | fontdict = {"family": "serif", "weight": "bold", "size": 25} 240 | import matplotlib.pyplot as plt 241 | 242 | plt.figure(figsize=(figsize, figsize)) 243 | self.plot_regions(fontdict=fontdict) 244 | # some maps has no vision blockers 245 | if len(self.map_data.vision_blockers) > 0: 246 | self.plot_vision_blockers() 247 | self.plot_normal_resources() 248 | self.plot_chokes() 249 | fontsize = 25 250 | 251 | plt.style.use("ggplot") 252 | plt.imshow(self.map_data.region_grid.astype(float), origin="lower") 253 | plt.imshow( 254 | self.map_data.terrain_height, alpha=1, origin="lower", cmap="terrain" 255 | ) 256 | nonpathable_indices = np.where(self.map_data.path_arr == 0) 257 | nonpathable_indices_stacked = np.column_stack( 258 | (nonpathable_indices[1], nonpathable_indices[0]) 259 | ) 260 | x, y = zip(*nonpathable_indices_stacked) 261 | plt.scatter(x, y, color="grey") 262 | ax = plt.gca() 263 | for tick in ax.xaxis.get_major_ticks(): 264 | tick.label1.set_fontsize(fontsize) 265 | tick.label1.set_fontweight("bold") 266 | for tick in ax.yaxis.get_major_ticks(): 267 | tick.label1.set_fontsize(fontsize) 268 | tick.label1.set_fontweight("bold") 269 | plt.grid() 270 | 271 | def plot_influenced_path( 272 | self, 273 | start: Union[Tuple[float, float], Point2], 274 | goal: Union[Tuple[float, float], Point2], 275 | weight_array: ndarray, 276 | large: bool = False, 277 | smoothing: bool = False, 278 | name: Optional[str] = None, 279 | fontdict: dict = None, 280 | ) -> None: 281 | import matplotlib.pyplot as plt 282 | from matplotlib.cm import ScalarMappable 283 | from mpl_toolkits.axes_grid1 import make_axes_locatable 284 | 285 | if not fontdict: 286 | fontdict = {"family": "serif", "weight": "bold", "size": 20} 287 | plt.style.use(["ggplot", "bmh"]) 288 | org = "lower" 289 | if name is None: 290 | name = self.map_data.map_name 291 | arr = weight_array.copy() 292 | path = self.map_data.pathfind( 293 | start, goal, grid=arr, large=large, smoothing=smoothing, sensitivity=1 294 | ) 295 | ax: plt.Axes = plt.subplot(1, 1, 1) 296 | if path is not None: 297 | path = np.flipud(path) # for plot align 298 | logger.info("Found") 299 | x, y = zip(*path) 300 | ax.scatter(x, y, s=3, c="green") 301 | else: 302 | logger.info("Not Found") 303 | 304 | x, y = zip(*[start, goal]) 305 | ax.scatter(x, y) 306 | 307 | influence_cmap = plt.cm.get_cmap("afmhot") 308 | ax.text(start[0], start[1], f"Start {start}") 309 | ax.text(goal[0], goal[1], f"Goal {goal}") 310 | ax.imshow(self.map_data.path_arr, alpha=0.5, origin=org) 311 | ax.imshow(self.map_data.terrain_height, alpha=0.5, origin=org, cmap="bone") 312 | arr = np.where(arr == np.inf, 0, arr).T 313 | ax.imshow(arr, origin=org, alpha=0.3, cmap=influence_cmap) 314 | divider = make_axes_locatable(ax) 315 | cax = divider.append_axes("right", size="5%", pad=0.05) 316 | sc = ScalarMappable(cmap=influence_cmap) 317 | sc.set_array(arr) 318 | sc.autoscale() 319 | cbar = plt.colorbar(sc, cax=cax) 320 | cbar.ax.set_ylabel("Pathing Cost", rotation=270, labelpad=25, fontdict=fontdict) 321 | plt.title(f"{name}", fontdict=fontdict, loc="right") 322 | plt.grid() 323 | 324 | def plot_influenced_path_nydus( 325 | self, 326 | start: Union[Tuple[float, float], Point2], 327 | goal: Union[Tuple[float, float], Point2], 328 | weight_array: ndarray, 329 | large: bool = False, 330 | smoothing: bool = False, 331 | name: Optional[str] = None, 332 | fontdict: dict = None, 333 | ) -> None: 334 | import matplotlib.pyplot as plt 335 | from matplotlib.cm import ScalarMappable 336 | from mpl_toolkits.axes_grid1 import make_axes_locatable 337 | 338 | if not fontdict: 339 | fontdict = {"family": "serif", "weight": "bold", "size": 20} 340 | plt.style.use(["ggplot", "bmh"]) 341 | org = "lower" 342 | if name is None: 343 | name = self.map_data.map_name 344 | arr = weight_array.copy() 345 | paths = self.map_data.pathfind_with_nyduses( 346 | start, goal, grid=arr, large=large, smoothing=smoothing, sensitivity=1 347 | ) 348 | ax: plt.Axes = plt.subplot(1, 1, 1) 349 | if paths is not None: 350 | for i in range(len(paths[0])): 351 | path = np.flipud(paths[0][i]) # for plot align 352 | logger.info("Found") 353 | x, y = zip(*path) 354 | ax.scatter(x, y, s=3, c="green") 355 | else: 356 | logger.info("Not Found") 357 | 358 | x, y = zip(*[start, goal]) 359 | ax.scatter(x, y) 360 | 361 | influence_cmap = plt.cm.get_cmap("afmhot") 362 | ax.text(start[0], start[1], f"Start {start}") 363 | ax.text(goal[0], goal[1], f"Goal {goal}") 364 | ax.imshow(self.map_data.path_arr, alpha=0.5, origin=org) 365 | ax.imshow(self.map_data.terrain_height, alpha=0.5, origin=org, cmap="bone") 366 | arr = np.where(arr == np.inf, 0, arr).T 367 | ax.imshow(arr, origin=org, alpha=0.3, cmap=influence_cmap) 368 | divider = make_axes_locatable(ax) 369 | cax = divider.append_axes("right", size="5%", pad=0.05) 370 | sc = ScalarMappable(cmap=influence_cmap) 371 | sc.set_array(arr) 372 | sc.autoscale() 373 | cbar = plt.colorbar(sc, cax=cax) 374 | cbar.ax.set_ylabel("Pathing Cost", rotation=270, labelpad=25, fontdict=fontdict) 375 | plt.title(f"{name}", fontdict=fontdict, loc="right") 376 | plt.grid() 377 | 378 | @staticmethod 379 | def draw_influence_in_game( 380 | bot: BotAI, 381 | grid: np.ndarray, 382 | lower_threshold: int, 383 | upper_threshold: int, 384 | color: Tuple[int, int, int], 385 | size: int, 386 | ) -> None: 387 | height: float = bot.get_terrain_z_height(bot.start_location) 388 | for x, y in zip(*np.where((grid > lower_threshold) & (grid < upper_threshold))): 389 | pos: Point3 = Point3((x, y, height)) 390 | if grid[x, y] == np.inf: 391 | val: int = 9999 392 | else: 393 | val: int = int(grid[x, y]) 394 | bot.client.debug_text_world(str(val), pos, color, size) 395 | -------------------------------------------------------------------------------- /map_analyzer/Pather.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING, List, Optional, Tuple 2 | 3 | import numpy as np 4 | from loguru import logger 5 | from numpy import ndarray 6 | from sc2.ids.unit_typeid import UnitTypeId 7 | from sc2.position import Point2 8 | 9 | from map_analyzer.exceptions import PatherNoPointsException 10 | from map_analyzer.Region import Region 11 | from map_analyzer.utils import change_destructable_status_in_grid 12 | 13 | from .cext import astar_path, astar_path_with_nyduses 14 | from .destructibles import buildings_2x2, buildings_3x3, buildings_5x5 15 | 16 | if TYPE_CHECKING: 17 | from map_analyzer.MapData import MapData 18 | 19 | 20 | def _bounded_circle(center, radius, shape): 21 | xx, yy = np.ogrid[: shape[0], : shape[1]] 22 | circle = (xx - center[0]) ** 2 + (yy - center[1]) ** 2 23 | return np.nonzero(circle <= radius**2) 24 | 25 | 26 | def draw_circle(c, radius, shape=None): 27 | center = np.array(c) 28 | upper_left = np.ceil(center - radius).astype(int) 29 | lower_right = np.floor(center + radius).astype(int) + 1 30 | 31 | if shape is not None: 32 | # Constrain upper_left and lower_right by shape boundary. 33 | upper_left = np.maximum(upper_left, 0) 34 | lower_right = np.minimum(lower_right, np.array(shape)) 35 | 36 | shifted_center = center - upper_left 37 | bounding_shape = lower_right - upper_left 38 | 39 | rr, cc = _bounded_circle(shifted_center, radius, bounding_shape) 40 | return rr + upper_left[0], cc + upper_left[1] 41 | 42 | 43 | class MapAnalyzerPather: 44 | """""" 45 | 46 | def __init__(self, map_data: "MapData") -> None: 47 | self.map_data = map_data 48 | 49 | self.connectivity_graph = None # set later by MapData 50 | 51 | self._set_default_grids() 52 | self.terrain_height = self.map_data.terrain_height.copy().T 53 | 54 | def _set_default_grids(self): 55 | # need to consider the placement arr because our 56 | # base minerals, geysers and townhall 57 | # are not pathable in the pathing grid 58 | # we manage those manually so they are accurate through the game 59 | self.default_grid = np.fmax( 60 | self.map_data.path_arr, self.map_data.placement_arr 61 | ).T 62 | 63 | # Fixing platforms on Submarine which reapers can climb onto not being pathable 64 | # Don't use the entire name because we also use the modified maps 65 | # with different names 66 | if "Submarine" in self.map_data.map_name: 67 | self.default_grid[116, 43] = 1 68 | self.default_grid[51, 120] = 1 69 | 70 | self.default_grid_nodestr = self.default_grid.copy() 71 | 72 | self.destructables_included = {} 73 | self.minerals_included = {} 74 | 75 | # set rocks and mineral walls to pathable in the beginning 76 | # these will be set nonpathable when updating grids for the destructables 77 | # that still exist 78 | for dest in self.map_data.bot.destructables: 79 | self.destructables_included[dest.position] = dest 80 | if ( 81 | "unbuildable" not in dest.name.lower() 82 | and "acceleration" not in dest.name.lower() 83 | ): 84 | change_destructable_status_in_grid(self.default_grid, dest, 0) 85 | change_destructable_status_in_grid(self.default_grid_nodestr, dest, 1) 86 | 87 | # set each geyser as non pathable, these don't update during the game 88 | for geyser in self.map_data.bot.vespene_geyser: 89 | left_bottom = geyser.position.offset((-1.5, -1.5)) 90 | x_start = int(left_bottom[0]) 91 | y_start = int(left_bottom[1]) 92 | x_end = int(x_start + 3) 93 | y_end = int(y_start + 3) 94 | self.default_grid[x_start:x_end, y_start:y_end] = 0 95 | self.default_grid_nodestr[x_start:x_end, y_start:y_end] = 0 96 | 97 | for mineral in self.map_data.bot.mineral_field: 98 | self.minerals_included[mineral.position] = mineral 99 | x1 = int(mineral.position[0]) 100 | x2 = x1 - 1 101 | y = int(mineral.position[1]) 102 | 103 | self.default_grid[x1, y] = 0 104 | self.default_grid[x2, y] = 0 105 | self.default_grid_nodestr[x1, y] = 0 106 | self.default_grid_nodestr[x2, y] = 0 107 | 108 | def set_connectivity_graph(self): 109 | connectivity_graph = {} 110 | for region in self.map_data.regions.values(): 111 | if connectivity_graph.get(region) is None: 112 | connectivity_graph[region] = [] 113 | for connected_region in region.connected_regions: 114 | if connected_region not in connectivity_graph.get(region): 115 | connectivity_graph[region].append(connected_region) 116 | self.connectivity_graph = connectivity_graph 117 | 118 | def find_all_paths( 119 | self, start: Region, goal: Region, path: Optional[List[Region]] = None 120 | ) -> List[List[Region]]: 121 | if path is None: 122 | path = [] 123 | graph = self.connectivity_graph 124 | path = path + [start] 125 | if start == goal: 126 | return [path] 127 | if start not in graph: 128 | return [] 129 | paths = [] 130 | for node in graph[start]: 131 | if node not in path: 132 | newpaths = self.find_all_paths(node, goal, path) 133 | for newpath in newpaths: 134 | paths.append(newpath) 135 | return paths 136 | 137 | def _add_non_pathables_ground( 138 | self, grid: ndarray, include_destructables: bool = True 139 | ) -> ndarray: 140 | ret_grid = grid.copy() 141 | nonpathables = self.map_data.bot.structures.not_flying 142 | nonpathables.extend(self.map_data.bot.enemy_structures.not_flying) 143 | nonpathables = nonpathables.filter( 144 | lambda x: (x.type_id != UnitTypeId.SUPPLYDEPOTLOWERED or x.is_active) 145 | and (x.type_id != UnitTypeId.CREEPTUMOR or not x.is_ready) 146 | ) 147 | 148 | for obj in nonpathables: 149 | size = 1 150 | if obj.type_id in buildings_2x2: 151 | size = 2 152 | elif obj.type_id in buildings_3x3: 153 | size = 3 154 | elif obj.type_id in buildings_5x5: 155 | size = 5 156 | left_bottom = obj.position.offset((-size / 2, -size / 2)) 157 | x_start = int(left_bottom[0]) 158 | y_start = int(left_bottom[1]) 159 | x_end = int(x_start + size) 160 | y_end = int(y_start + size) 161 | 162 | ret_grid[x_start:x_end, y_start:y_end] = 0 163 | 164 | # townhall sized buildings should have their corner spots pathable 165 | if size == 5: 166 | ret_grid[x_start, y_start] = 1 167 | ret_grid[x_start, y_end - 1] = 1 168 | ret_grid[x_end - 1, y_start] = 1 169 | ret_grid[x_end - 1, y_end - 1] = 1 170 | 171 | if len(self.minerals_included) != self.map_data.bot.mineral_field.amount: 172 | new_positions = set(m.position for m in self.map_data.bot.mineral_field) 173 | old_mf_positions = set(self.minerals_included) 174 | 175 | missing_positions = old_mf_positions - new_positions 176 | for mf_position in missing_positions: 177 | x1 = int(mf_position[0]) 178 | x2 = x1 - 1 179 | y = int(mf_position[1]) 180 | 181 | ret_grid[x1, y] = 1 182 | ret_grid[x2, y] = 1 183 | 184 | self.default_grid[x1, y] = 1 185 | self.default_grid[x2, y] = 1 186 | 187 | self.default_grid_nodestr[x1, y] = 1 188 | self.default_grid_nodestr[x2, y] = 1 189 | 190 | del self.minerals_included[mf_position] 191 | 192 | if ( 193 | include_destructables 194 | and len(self.destructables_included) 195 | != self.map_data.bot.destructables.amount 196 | ): 197 | new_positions = set(d.position for d in self.map_data.bot.destructables) 198 | old_dest_positions = set(self.destructables_included) 199 | missing_positions = old_dest_positions - new_positions 200 | 201 | for dest_position in missing_positions: 202 | dest = self.destructables_included[dest_position] 203 | change_destructable_status_in_grid(ret_grid, dest, 1) 204 | change_destructable_status_in_grid(self.default_grid, dest, 1) 205 | 206 | del self.destructables_included[dest_position] 207 | 208 | return ret_grid 209 | 210 | def find_eligible_point( 211 | self, 212 | point: Tuple[float, float], 213 | grid: np.ndarray, 214 | terrain_height: np.ndarray, 215 | max_distance: float, 216 | ) -> Optional[Tuple[int, int]]: 217 | """ 218 | User may give a point that is in a nonpathable grid cell, for example 219 | inside a building or inside rocks. The desired behavior is to move the 220 | point a bit so for example we can start or finish next to the building 221 | the original point was inside of. To make sure that we don't accidentally 222 | for example offer a point that is on low ground when the first target 223 | was on high ground, we first try to find a point that maintains the 224 | terrain height. 225 | After that we check for points on other terrain heights. 226 | """ 227 | point = (int(point[0]), int(point[1])) 228 | 229 | if grid[point] == np.inf: 230 | target_height = terrain_height[point] 231 | disk = tuple(draw_circle(point, max_distance, shape=grid.shape)) 232 | # Using 8 for the threshold in case there is some variance on the same level 233 | # Proper levels have a height difference of 16 234 | same_height_cond = np.logical_and( 235 | np.abs(terrain_height[disk] - target_height) < 8, grid[disk] < np.inf 236 | ) 237 | 238 | if np.any(same_height_cond): 239 | possible_points = np.column_stack( 240 | (disk[0][same_height_cond], disk[1][same_height_cond]) 241 | ) 242 | closest_point_index = np.argmin( 243 | np.sum((possible_points - point) ** 2, axis=1) 244 | ) 245 | return tuple(possible_points[closest_point_index]) 246 | else: 247 | diff_height_cond = grid[disk] < np.inf 248 | if np.any(diff_height_cond): 249 | possible_points = np.column_stack( 250 | (disk[0][diff_height_cond], disk[1][diff_height_cond]) 251 | ) 252 | closest_point_index = np.argmin( 253 | np.sum((possible_points - point) ** 2, axis=1) 254 | ) 255 | return tuple(possible_points[closest_point_index]) 256 | else: 257 | return None 258 | return point 259 | 260 | def lowest_cost_points_array( 261 | self, from_pos: tuple, radius: float, grid: np.ndarray 262 | ) -> Optional[np.ndarray]: 263 | """For use with evaluations that use numpy arrays 264 | example: 265 | # Closest point to unit furthest from target 266 | distances = cdist([[unitpos, targetpos]], lowest_points, "sqeuclidean") 267 | lowest_points[(distances[0] - distances[1]).argmin()] 268 | - 140 µs per loop 269 | """ 270 | 271 | disk = tuple(draw_circle(from_pos, radius, shape=grid.shape)) 272 | if len(disk[0]) == 0: 273 | return None 274 | 275 | arrmin = np.min(grid[disk]) 276 | cond = grid[disk] == arrmin 277 | return np.column_stack((disk[0][cond], disk[1][cond])) 278 | 279 | def find_lowest_cost_points( 280 | self, from_pos: Point2, radius: float, grid: np.ndarray 281 | ) -> Optional[List[Point2]]: 282 | lowest = self.lowest_cost_points_array(from_pos, radius, grid) 283 | 284 | if lowest is None: 285 | return None 286 | 287 | return list(map(Point2, lowest)) 288 | 289 | def get_base_pathing_grid(self, include_destructables: bool = True) -> ndarray: 290 | if include_destructables: 291 | return self.default_grid.copy() 292 | else: 293 | return self.default_grid_nodestr.copy() 294 | 295 | def get_climber_grid( 296 | self, default_weight: float = 1, include_destructables: bool = True 297 | ) -> ndarray: 298 | """Grid for units like reaper / colossus""" 299 | grid = self.get_base_pathing_grid(include_destructables) 300 | grid = np.where(self.map_data.c_ext_map.climber_grid != 0, 1, grid) 301 | grid = self._add_non_pathables_ground( 302 | grid=grid, include_destructables=include_destructables 303 | ) 304 | grid = np.where(grid != 0, default_weight, np.inf).astype(np.float32) 305 | return grid 306 | 307 | def get_clean_air_grid(self, default_weight: float = 1) -> ndarray: 308 | clean_air_grid = np.zeros(shape=self.default_grid.shape).astype(np.float32) 309 | area = self.map_data.bot.game_info.playable_area 310 | clean_air_grid[ 311 | area.x : (area.x + area.width), area.y : (area.y + area.height) 312 | ] = 1 313 | return np.where(clean_air_grid == 1, default_weight, np.inf).astype(np.float32) 314 | 315 | def get_air_vs_ground_grid(self, default_weight: float) -> ndarray: 316 | grid = self.get_pyastar_grid( 317 | default_weight=default_weight, include_destructables=True 318 | ) 319 | # set non pathable points inside map bounds to value 1 320 | area = self.map_data.bot.game_info.playable_area 321 | start_x = area.x 322 | end_x = area.x + area.width 323 | start_y = area.y 324 | end_y = area.y + area.height 325 | grid[start_x:end_x, start_y:end_y] = np.where( 326 | grid[start_x:end_x, start_y:end_y] == np.inf, 1, default_weight 327 | ) 328 | 329 | return grid 330 | 331 | def get_pyastar_grid( 332 | self, default_weight: float = 1, include_destructables: bool = True 333 | ) -> ndarray: 334 | grid = self.get_base_pathing_grid(include_destructables) 335 | grid = self._add_non_pathables_ground( 336 | grid=grid, include_destructables=include_destructables 337 | ) 338 | 339 | grid = np.where(grid != 0, default_weight, np.inf).astype(np.float32) 340 | return grid 341 | 342 | def pathfind( 343 | self, 344 | start: Tuple[float, float], 345 | goal: Tuple[float, float], 346 | grid: Optional[ndarray] = None, 347 | large: bool = False, 348 | smoothing: bool = False, 349 | sensitivity: int = 1, 350 | ) -> Optional[List[Point2]]: 351 | if grid is None: 352 | logger.warning("Using the default pyastar grid as no grid was provided.") 353 | grid = self.get_pyastar_grid() 354 | 355 | if start is not None and goal is not None: 356 | start = round(start[0]), round(start[1]) 357 | start = self.find_eligible_point(start, grid, self.terrain_height, 10) 358 | goal = round(goal[0]), round(goal[1]) 359 | goal = self.find_eligible_point(goal, grid, self.terrain_height, 10) 360 | else: 361 | logger.warning(PatherNoPointsException(start=start, goal=goal)) 362 | return None 363 | 364 | # find_eligible_point didn't find any pathable nodes nearby 365 | if start is None or goal is None: 366 | return None 367 | 368 | path = astar_path(grid, start, goal, large, smoothing) 369 | 370 | if path is not None: 371 | # Remove the starting point from the path. 372 | # Make sure the goal node is the last node even if we are 373 | # skipping points 374 | complete_path = list(map(Point2, path)) 375 | skipped_path = complete_path[0:-1:sensitivity] 376 | if skipped_path: 377 | skipped_path.pop(0) 378 | 379 | skipped_path.append(complete_path[-1]) 380 | 381 | return skipped_path 382 | else: 383 | logger.debug(f"No Path found s{start}, g{goal}") 384 | return None 385 | 386 | def pathfind_with_nyduses( 387 | self, 388 | start: Tuple[float, float], 389 | goal: Tuple[float, float], 390 | grid: Optional[ndarray] = None, 391 | large: bool = False, 392 | smoothing: bool = False, 393 | sensitivity: int = 1, 394 | ) -> Optional[Tuple[List[List[Point2]], Optional[List[int]]]]: 395 | if grid is None: 396 | logger.warning("Using the default pyastar grid as no grid was provided.") 397 | grid = self.get_pyastar_grid() 398 | 399 | if start is not None and goal is not None: 400 | start = round(start[0]), round(start[1]) 401 | start = self.find_eligible_point(start, grid, self.terrain_height, 10) 402 | goal = round(goal[0]), round(goal[1]) 403 | goal = self.find_eligible_point(goal, grid, self.terrain_height, 10) 404 | else: 405 | logger.warning(PatherNoPointsException(start=start, goal=goal)) 406 | return None 407 | 408 | # find_eligible_point didn't find any pathable nodes nearby 409 | if start is None or goal is None: 410 | return None 411 | 412 | nydus_units = self.map_data.bot.structures.of_type( 413 | [UnitTypeId.NYDUSNETWORK, UnitTypeId.NYDUSCANAL] 414 | ).ready 415 | nydus_positions = [nydus.position for nydus in nydus_units] 416 | 417 | paths = astar_path_with_nyduses( 418 | grid, start, goal, nydus_positions, large, smoothing 419 | ) 420 | if paths is not None: 421 | returned_path = [] 422 | nydus_tags = None 423 | if len(paths) == 1: 424 | path = list(map(Point2, paths[0])) 425 | skipped_path = path[0:-1:sensitivity] 426 | if skipped_path: 427 | skipped_path.pop(0) 428 | skipped_path.append(path[-1]) 429 | returned_path.append(skipped_path) 430 | else: 431 | first_path = list(map(Point2, paths[0])) 432 | first_skipped_path = first_path[0:-1:sensitivity] 433 | if first_skipped_path: 434 | first_skipped_path.pop(0) 435 | first_skipped_path.append(first_path[-1]) 436 | returned_path.append(first_skipped_path) 437 | 438 | enter_nydus_unit = nydus_units.filter( 439 | lambda x: x.position.rounded == first_path[-1] 440 | ).first 441 | enter_nydus_tag = enter_nydus_unit.tag 442 | 443 | second_path = list(map(Point2, paths[1])) 444 | exit_nydus_unit = nydus_units.filter( 445 | lambda x: x.position.rounded == second_path[0] 446 | ).first 447 | exit_nydus_tag = exit_nydus_unit.tag 448 | nydus_tags = [enter_nydus_tag, exit_nydus_tag] 449 | 450 | second_skipped_path = second_path[0:-1:sensitivity] 451 | second_skipped_path.append(second_path[-1]) 452 | returned_path.append(second_skipped_path) 453 | 454 | return returned_path, nydus_tags 455 | else: 456 | logger.debug(f"No Path found s{start}, g{goal}") 457 | return None 458 | 459 | def add_cost( 460 | self, 461 | position: Tuple[float, float], 462 | radius: float, 463 | arr: ndarray, 464 | weight: float = 100, 465 | safe: bool = True, 466 | initial_default_weights: float = 0, 467 | ) -> ndarray: 468 | disk = tuple(draw_circle(position, radius, arr.shape)) 469 | 470 | arr: ndarray = self._add_disk_to_grid( 471 | position, arr, disk, weight, safe, initial_default_weights 472 | ) 473 | 474 | return arr 475 | 476 | def add_cost_to_multiple_grids( 477 | self, 478 | position: Tuple[float, float], 479 | radius: float, 480 | arrays: List[ndarray], 481 | weight: float = 100, 482 | safe: bool = True, 483 | initial_default_weights: float = 0, 484 | ) -> List[ndarray]: 485 | """ 486 | Add the same cost to multiple grids, this is so the disk is only calculated once 487 | """ 488 | disk = tuple(draw_circle(position, radius, arrays[0].shape)) 489 | 490 | for i, arr in enumerate(arrays): 491 | arrays[i] = self._add_disk_to_grid( 492 | position, arrays[i], disk, weight, safe, initial_default_weights 493 | ) 494 | 495 | return arrays 496 | 497 | @staticmethod 498 | def _add_disk_to_grid( 499 | position: Tuple[float, float], 500 | arr: ndarray, 501 | disk: Tuple, 502 | weight: float = 100, 503 | safe: bool = True, 504 | initial_default_weights: float = 0, 505 | ) -> ndarray: 506 | # if we don't touch any cell origins due to a small radius, 507 | # add at least the cell the given position is in 508 | if ( 509 | len(disk[0]) == 0 510 | and 0 <= position[0] < arr.shape[0] 511 | and 0 <= position[1] < arr.shape[1] 512 | ): 513 | disk = (int(position[0]), int(position[1])) 514 | 515 | if initial_default_weights > 0: 516 | arr[disk] = np.where(arr[disk] == 1, initial_default_weights, arr[disk]) 517 | 518 | arr[disk] += weight 519 | if safe and np.any(arr[disk] < 1): 520 | logger.warning( 521 | "You are attempting to set weights that are below 1." 522 | " falling back to the minimum (1)" 523 | ) 524 | arr[disk] = np.where(arr[disk] < 1, 1, arr[disk]) 525 | 526 | return arr 527 | -------------------------------------------------------------------------------- /map_analyzer/Polygon.py: -------------------------------------------------------------------------------- 1 | from functools import lru_cache 2 | from typing import TYPE_CHECKING, List, Set, Union 3 | 4 | import numpy as np 5 | from numpy import ndarray 6 | from sc2.position import Point2 7 | from scipy.ndimage import center_of_mass 8 | 9 | if TYPE_CHECKING: 10 | from map_analyzer import MapData, Region 11 | 12 | 13 | class Polygon: 14 | """ 15 | 16 | Base Class for Representing an "Area" 17 | 18 | """ 19 | 20 | # noinspection PyProtectedMember 21 | def __init__(self, map_data: "MapData", array: ndarray) -> None: # pragma: no cover 22 | self.map_data = map_data 23 | self.array = array 24 | self.extended_array = array.copy() 25 | # Include the outer_perimeter in the points 26 | outer_perimeter = self.outer_perimeter 27 | self.extended_array[outer_perimeter[:, 0], outer_perimeter[:, 1]] = 1 28 | self.id = None # TODO 29 | self.is_choke = False 30 | self.is_ramp = False 31 | self.is_vision_blocker = False 32 | self.is_region = False 33 | self.areas = [] # set by map_data / Region 34 | self.map_data.polygons.append(self) 35 | 36 | @property 37 | def top(self): 38 | return max(self.points, key=lambda x: x[1]) 39 | 40 | @property 41 | def bottom(self): 42 | return min(self.points, key=lambda x: x[1]) 43 | 44 | @property 45 | def right(self): 46 | return max(self.points, key=lambda x: x[0]) 47 | 48 | @property 49 | def left(self): 50 | return min(self.points, key=lambda x: x[0]) 51 | 52 | @property 53 | def regions(self) -> List["Region"]: 54 | """ 55 | 56 | :rtype: List[:class:`.Region`] 57 | 58 | Filters out every Polygon that is not a region, 59 | and is inside / bordering with ``self`` 60 | 61 | """ 62 | from map_analyzer.Region import Region 63 | 64 | if len(self.areas) > 0: 65 | return [r for r in self.areas if isinstance(r, Region)] 66 | return [] 67 | 68 | def calc_areas(self) -> None: 69 | # This is called by MapData, at a specific point in the 70 | # sequence of compiling the map this method uses where_all 71 | # which means it should be called at the end of the map 72 | # compilation when areas are populated 73 | 74 | areas = self.areas 75 | for point in self.outer_perimeter: 76 | point = point[0], point[1] 77 | new_areas = self.map_data.where_all(point) 78 | if self in new_areas: 79 | new_areas.pop(new_areas.index(self)) 80 | areas.extend(new_areas) 81 | self.areas = list(set(areas)) 82 | 83 | def plot(self, testing: bool = False) -> None: # pragma: no cover 84 | """ 85 | 86 | plot 87 | 88 | """ 89 | import matplotlib.pyplot as plt 90 | 91 | plt.style.use("ggplot") 92 | 93 | plt.imshow(self.array, origin="lower") 94 | if testing: 95 | return 96 | plt.show() 97 | 98 | @property 99 | @lru_cache() 100 | def points(self) -> Set[Point2]: 101 | """ 102 | 103 | Set of :class:`.Point2` 104 | 105 | """ 106 | return {Point2(p) for p in np.argwhere(self.extended_array == 1)} 107 | 108 | @property 109 | @lru_cache() 110 | def corner_array(self) -> ndarray: 111 | """ 112 | 113 | :rtype: :class:`.ndarray` 114 | 115 | """ 116 | 117 | from skimage.feature import corner_harris, corner_peaks 118 | 119 | array = corner_peaks( 120 | corner_harris(self.array), 121 | min_distance=self.map_data.corner_distance, 122 | threshold_rel=0.01, 123 | ) 124 | return array 125 | 126 | @property 127 | @lru_cache() 128 | def width(self) -> float: 129 | """ 130 | 131 | Lazy width calculation, will be approx 0.5 < x < 1.5 of real width 132 | 133 | """ 134 | pl = list(self.outer_perimeter_points) 135 | s1 = min(pl) 136 | s2 = max(pl) 137 | x1, y1 = s1[0], s1[1] 138 | x2, y2 = s2[0], s2[1] 139 | return np.math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2) 140 | 141 | @property 142 | @lru_cache() 143 | def corner_points(self) -> List[Point2]: 144 | """ 145 | 146 | :rtype: List[:class:`.Point2`] 147 | 148 | """ 149 | points = [ 150 | Point2((int(p[0]), int(p[1]))) 151 | for p in self.corner_array 152 | if self.is_inside_point(Point2(p)) 153 | ] 154 | return points 155 | 156 | @property 157 | @lru_cache() 158 | def center(self) -> Point2: 159 | """ 160 | 161 | Since the center is always going to be a ``float``, 162 | 163 | and for performance considerations we use integer coordinates. 164 | 165 | We will return the closest point registered 166 | 167 | """ 168 | 169 | cm = self.map_data.closest_towards_point( 170 | points=list(self.points), target=center_of_mass(self.array) 171 | ) 172 | return cm 173 | 174 | def is_inside_point(self, point: Union[Point2, tuple]) -> bool: 175 | """ 176 | 177 | Query via Set(Point2) ''fast'' 178 | 179 | """ 180 | test = int(point[0]), int(point[1]) 181 | shape = self.extended_array.shape 182 | if 0 < test[0] < shape[0] and 0 < test[1] < shape[1]: 183 | # Return python bool instead of numpy bool 184 | return_val = True if self.extended_array[test] == 1 else False 185 | return return_val 186 | return False 187 | 188 | @property 189 | def outer_perimeter(self) -> np.ndarray: 190 | """ 191 | Find all the individual points that surround the area 192 | """ 193 | d1 = np.diff(self.array, axis=0, prepend=0) 194 | d2 = np.diff(self.array, axis=1, prepend=0) 195 | d1_pos = np.argwhere(d1 > 0) - [1, 0] 196 | d1_neg = np.argwhere(d1 < 0) 197 | d2_pos = np.argwhere(d2 > 0) - [0, 1] 198 | d2_neg = np.argwhere(d2 < 0) 199 | perimeter_arr = np.zeros(self.array.shape) 200 | perimeter_arr[d1_pos[:, 0], d1_pos[:, 1]] = 1 201 | perimeter_arr[d1_neg[:, 0], d1_neg[:, 1]] = 1 202 | perimeter_arr[d2_pos[:, 0], d2_pos[:, 1]] = 1 203 | perimeter_arr[d2_neg[:, 0], d2_neg[:, 1]] = 1 204 | 205 | edge_indices = np.argwhere(perimeter_arr != 0) 206 | return edge_indices 207 | 208 | @property 209 | def outer_perimeter_points(self) -> Set[Point2]: 210 | """ 211 | 212 | Useful method for getting perimeter points 213 | 214 | """ 215 | return {Point2((p[0], p[1])) for p in self.outer_perimeter} 216 | 217 | @property 218 | @lru_cache() 219 | def perimeter(self) -> np.ndarray: 220 | """ 221 | Find all the individual points that surround the area 222 | """ 223 | d1 = np.diff(self.array, axis=0, prepend=0) 224 | d2 = np.diff(self.array, axis=1, prepend=0) 225 | d1_pos = np.argwhere(d1 > 0) 226 | d1_neg = np.argwhere(d1 < 0) - [1, 0] 227 | d2_pos = np.argwhere(d2 > 0) 228 | d2_neg = np.argwhere(d2 < 0) - [0, 1] 229 | perimeter_arr = np.zeros(self.array.shape) 230 | perimeter_arr[d1_pos[:, 0], d1_pos[:, 1]] = 1 231 | perimeter_arr[d1_neg[:, 0], d1_neg[:, 1]] = 1 232 | perimeter_arr[d2_pos[:, 0], d2_pos[:, 1]] = 1 233 | perimeter_arr[d2_neg[:, 0], d2_neg[:, 1]] = 1 234 | 235 | edge_indices = np.argwhere(perimeter_arr != 0) 236 | return edge_indices 237 | 238 | @property 239 | @lru_cache() 240 | def perimeter_points(self) -> Set[Point2]: 241 | """ 242 | 243 | Useful method for getting perimeter points 244 | 245 | """ 246 | return {Point2((p[0], p[1])) for p in self.perimeter} 247 | 248 | @property 249 | @lru_cache() 250 | # type hinting complains if ndarray is not here, but always returns a numpy.int32 251 | def area(self) -> Union[int, np.ndarray]: 252 | """ 253 | Sum of all points 254 | 255 | """ 256 | return np.sum(self.extended_array) 257 | 258 | def __repr__(self) -> str: 259 | return f"" 260 | -------------------------------------------------------------------------------- /map_analyzer/Region.py: -------------------------------------------------------------------------------- 1 | from functools import lru_cache 2 | from typing import TYPE_CHECKING, List 3 | 4 | import numpy as np 5 | from sc2.position import Point2 6 | 7 | from map_analyzer.constructs import ChokeArea, MDRamp 8 | from map_analyzer.Polygon import Polygon 9 | 10 | if TYPE_CHECKING: 11 | from map_analyzer import MapData 12 | 13 | 14 | class Region(Polygon): 15 | """ 16 | Higher order "Area" , all of the maps can be summed up by it's :class:`.Region` 17 | 18 | Tip: 19 | A :class:`.Region` may contain other :class:`.Polygon` inside it, 20 | 21 | Such as :class:`.ChokeArea` and :class:`.MDRamp`. 22 | 23 | But it will never share a point with another :class:`.Region` 24 | 25 | """ 26 | 27 | def __init__( 28 | self, 29 | map_data: "MapData", 30 | array: np.ndarray, 31 | label: int, 32 | map_expansions: List[Point2], 33 | ) -> None: 34 | super().__init__(map_data=map_data, array=array) 35 | self.label = label 36 | self.is_region = True 37 | self.bases = [ 38 | base 39 | for base in map_expansions 40 | if self.is_inside_point((base.rounded[0], base.rounded[1])) 41 | ] # will be set later by mapdata 42 | self.region_vision_blockers = [] # will be set later by mapdata 43 | self.region_vb = [] 44 | 45 | @property 46 | def region_ramps(self) -> List[MDRamp]: 47 | """ 48 | 49 | Property access to :class:`.MDRamp` of this region 50 | 51 | """ 52 | return [r for r in self.areas if r.is_ramp] 53 | 54 | @property 55 | def region_chokes(self) -> List[ChokeArea]: 56 | """ 57 | 58 | Property access to :class:`.ChokeArea` of this region 59 | 60 | """ 61 | return [r for r in self.areas if r.is_choke] 62 | 63 | @property 64 | @lru_cache() 65 | def connected_regions(self): 66 | """ 67 | 68 | Provides a list of :class:`.Region` that are connected by chokes to ``self`` 69 | 70 | """ 71 | connected_regions = [] 72 | for choke in self.region_chokes: 73 | for region in choke.regions: 74 | if region is not self and region not in connected_regions: 75 | connected_regions.append(region) 76 | return connected_regions 77 | 78 | def plot_perimeter(self, self_only: bool = True) -> None: 79 | """ 80 | 81 | Debug Method plot_perimeter 82 | 83 | """ 84 | import matplotlib.pyplot as plt 85 | 86 | plt.style.use("ggplot") 87 | 88 | x, y = zip(*self.perimeter) 89 | plt.scatter(x, y) 90 | plt.title(f"Region {self.label}") 91 | if self_only: # pragma: no cover 92 | plt.grid() 93 | 94 | def _plot_corners(self) -> None: 95 | import matplotlib.pyplot as plt 96 | 97 | plt.style.use("ggplot") 98 | for corner in self.corner_points: 99 | plt.scatter(corner[0], corner[1], marker="v", c="red", s=150) 100 | 101 | def _plot_ramps(self) -> None: 102 | import matplotlib.pyplot as plt 103 | 104 | plt.style.use("ggplot") 105 | for ramp in self.region_ramps: 106 | plt.text( 107 | # fixme make ramp attr compatible and not reversed 108 | ramp.top_center[0], 109 | ramp.top_center[1], 110 | f"R<{[r.label for r in ramp.regions]}>", 111 | bbox=dict(fill=True, alpha=0.3, edgecolor="cyan", linewidth=8), 112 | ) 113 | # ramp.plot(testing=True) 114 | x, y = zip(*ramp.points) 115 | plt.scatter(x, y, color="w") 116 | 117 | def _plot_vision_blockers(self) -> None: 118 | import matplotlib.pyplot as plt 119 | 120 | plt.style.use("ggplot") 121 | for vb in self.map_data.vision_blockers: 122 | if self.is_inside_point(point=vb): 123 | plt.text(vb[0], vb[1], "X", c="r") 124 | 125 | def _plot_minerals(self) -> None: 126 | import matplotlib.pyplot as plt 127 | 128 | plt.style.use("ggplot") 129 | for mineral_field in self.map_data.mineral_fields: 130 | if self.is_inside_point(mineral_field.position.rounded): 131 | plt.scatter( 132 | mineral_field.position[0], mineral_field.position[1], color="blue" 133 | ) 134 | 135 | def _plot_geysers(self) -> None: 136 | import matplotlib.pyplot as plt 137 | 138 | plt.style.use("ggplot") 139 | for gasgeyser in self.map_data.normal_geysers: 140 | if self.is_inside_point(gasgeyser.position.rounded): 141 | plt.scatter( 142 | gasgeyser.position[0], 143 | gasgeyser.position[1], 144 | color="yellow", 145 | marker=r"$\spadesuit$", 146 | s=500, 147 | edgecolors="g", 148 | ) 149 | 150 | def plot(self, self_only: bool = True, testing: bool = False) -> None: 151 | """ 152 | 153 | Debug Method plot 154 | 155 | """ 156 | import matplotlib.pyplot as plt 157 | 158 | plt.style.use("ggplot") 159 | self._plot_geysers() 160 | self._plot_minerals() 161 | self._plot_ramps() 162 | self._plot_vision_blockers() 163 | self._plot_corners() 164 | if testing: 165 | self.plot_perimeter(self_only=False) 166 | return 167 | if self_only: # pragma: no cover 168 | self.plot_perimeter(self_only=True) 169 | else: # pragma: no cover 170 | self.plot_perimeter(self_only=False) 171 | 172 | @property 173 | def base_locations(self) -> List[Point2]: 174 | """ 175 | 176 | base_locations inside ``self`` 177 | 178 | """ 179 | return self.bases 180 | 181 | def __repr__(self) -> str: # pragma: no cover 182 | return "Region " + str(self.label) 183 | -------------------------------------------------------------------------------- /map_analyzer/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = "0.2.0" 2 | 3 | # flake8: noqa 4 | from pkg_resources import DistributionNotFound, get_distribution 5 | 6 | from .constructs import ChokeArea, MDRamp, VisionBlockerArea 7 | from .MapData import MapData 8 | from .Polygon import Polygon 9 | from .Region import Region 10 | -------------------------------------------------------------------------------- /map_analyzer/cext/__init__.py: -------------------------------------------------------------------------------- 1 | from .wrapper import CMapChoke, CMapInfo, astar_path, astar_path_with_nyduses 2 | -------------------------------------------------------------------------------- /map_analyzer/cext/mapanalyzerext.so: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spudde123/SC2MapAnalysis/1e76decb33b9c622f90ef7e91f60ae652b25c8a7/map_analyzer/cext/mapanalyzerext.so -------------------------------------------------------------------------------- /map_analyzer/cext/wrapper.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | 3 | try: 4 | from .mapanalyzerext import astar as ext_astar 5 | from .mapanalyzerext import astar_with_nydus as ext_astar_nydus 6 | from .mapanalyzerext import get_map_data as ext_get_map_data 7 | except ImportError: 8 | from mapanalyzerext import ( 9 | astar as ext_astar, 10 | astar_with_nydus as ext_astar_nydus, 11 | get_map_data as ext_get_map_data, 12 | ) 13 | 14 | from typing import List, Optional, Set, Tuple, Union 15 | 16 | from sc2.position import Point2, Rect 17 | 18 | 19 | class CMapChoke: 20 | """ 21 | CMapChoke holds the choke data coming from c extension 22 | main_line pair of floats representing the middle points of the sides of the choke 23 | lines all the lines from side to side 24 | side1 points on side1 25 | side2 points on side2 26 | pixels all the points inside the choke area, should include the sides 27 | and the points inside 28 | min_length minimum distance between the sides of the choke 29 | id an integer to represent the choke 30 | """ 31 | 32 | main_line: Tuple[Tuple[float, float], Tuple[float, float]] 33 | lines: List[Tuple[Tuple[int, int], Tuple[int, int]]] 34 | side1: List[Tuple[int, int]] 35 | side2: List[Tuple[int, int]] 36 | pixels: Set[Tuple[int, int]] 37 | min_length: float 38 | id: int 39 | 40 | def __init__(self, choke_id, main_line, lines, side1, side2, pixels, min_length): 41 | self.id = choke_id 42 | self.main_line = main_line 43 | self.lines = lines 44 | self.side1 = side1 45 | self.side2 = side2 46 | self.pixels = set(pixels) 47 | self.min_length = min_length 48 | 49 | def __repr__(self) -> str: 50 | return f"[{self.id}]CMapChoke; {len(self.pixels)}" 51 | 52 | 53 | # each map can have a list of exceptions, each 54 | # exception should be a type where we can index into a grid 55 | # grid[ex[0], ex[1]] = ... 56 | # meshgrid is used to build rectangular areas we can alter 57 | # in one go 58 | climber_grid_exceptions = { 59 | "DeathAura": [ 60 | np.meshgrid(range(36, 49), range(118, 127)), 61 | np.meshgrid(range(143, 154), range(61, 70)), 62 | ] 63 | } 64 | 65 | 66 | def astar_path( 67 | weights: np.ndarray, 68 | start: Tuple[int, int], 69 | goal: Tuple[int, int], 70 | large: bool = False, 71 | smoothing: bool = False, 72 | ) -> Union[np.ndarray, None]: 73 | # For the heuristic to be valid, each move must have a positive cost. 74 | # Demand costs above 1 so floating point inaccuracies aren't a problem 75 | # when comparing costs 76 | if weights.min(axis=None) < 1: 77 | raise ValueError( 78 | "Minimum cost to move must be above or equal to 1, but got %f" 79 | % (weights.min(axis=None)) 80 | ) 81 | # Ensure start is within bounds. 82 | if ( 83 | start[0] < 0 84 | or start[0] >= weights.shape[0] 85 | or start[1] < 0 86 | or start[1] >= weights.shape[1] 87 | ): 88 | raise ValueError(f"Start of {start} lies outside grid.") 89 | # Ensure goal is within bounds. 90 | if ( 91 | goal[0] < 0 92 | or goal[0] >= weights.shape[0] 93 | or goal[1] < 0 94 | or goal[1] >= weights.shape[1] 95 | ): 96 | raise ValueError(f"Goal of {goal} lies outside grid.") 97 | 98 | height, width = weights.shape 99 | start_idx = np.ravel_multi_index(start, (height, width)) 100 | goal_idx = np.ravel_multi_index(goal, (height, width)) 101 | 102 | path = ext_astar( 103 | weights.flatten(), height, width, start_idx, goal_idx, large, smoothing 104 | ) 105 | 106 | return path 107 | 108 | 109 | def astar_path_with_nyduses( 110 | weights: np.ndarray, 111 | start: Tuple[int, int], 112 | goal: Tuple[int, int], 113 | nydus_positions: List[Point2], 114 | large: bool = False, 115 | smoothing: bool = False, 116 | ) -> Union[List[np.ndarray], None]: 117 | # For the heuristic to be valid, each move must have a positive cost. 118 | # Demand costs above 1 so floating point inaccuracies aren't a problem 119 | # when comparing costs 120 | if weights.min(axis=None) < 1: 121 | raise ValueError( 122 | "Minimum cost to move must be above or equal to 1, but got %f" 123 | % (weights.min(axis=None)) 124 | ) 125 | # Ensure start is within bounds. 126 | if ( 127 | start[0] < 0 128 | or start[0] >= weights.shape[0] 129 | or start[1] < 0 130 | or start[1] >= weights.shape[1] 131 | ): 132 | raise ValueError(f"Start of {start} lies outside grid.") 133 | # Ensure goal is within bounds. 134 | if ( 135 | goal[0] < 0 136 | or goal[0] >= weights.shape[0] 137 | or goal[1] < 0 138 | or goal[1] >= weights.shape[1] 139 | ): 140 | raise ValueError(f"Goal of {goal} lies outside grid.") 141 | 142 | height, width = weights.shape 143 | start_idx = np.ravel_multi_index(start, (height, width)) 144 | goal_idx = np.ravel_multi_index(goal, (height, width)) 145 | nydus_array = np.zeros((len(nydus_positions),), dtype=np.int32) 146 | 147 | for index, pos in enumerate(nydus_positions): 148 | nydus_idx = np.ravel_multi_index((int(pos.x), int(pos.y)), (height, width)) 149 | nydus_array[index] = nydus_idx 150 | 151 | path = ext_astar_nydus( 152 | weights.flatten(), 153 | height, 154 | width, 155 | nydus_array.flatten(), 156 | start_idx, 157 | goal_idx, 158 | large, 159 | smoothing, 160 | ) 161 | 162 | return path 163 | 164 | 165 | class CMapInfo: 166 | climber_grid: np.ndarray 167 | overlord_spots: Optional[List[Point2]] 168 | chokes: List[CMapChoke] 169 | 170 | def __init__( 171 | self, 172 | walkable_grid: np.ndarray, 173 | height_map: np.ndarray, 174 | playable_area: Rect, 175 | map_name: str, 176 | ): 177 | """ 178 | walkable_grid and height_map are matrices of type uint8 179 | """ 180 | 181 | # grids are transposed and the c extension atm calls 182 | # the y axis the x axis and vice versa 183 | # so switch the playable area limits around 184 | c_start_y = int(playable_area.x) 185 | c_end_y = int(playable_area.x + playable_area.width) 186 | c_start_x = int(playable_area.y) 187 | c_end_x = int(playable_area.y + playable_area.height) 188 | 189 | self.climber_grid, overlord_data, choke_data = self._get_map_data( 190 | walkable_grid, height_map, c_start_y, c_end_y, c_start_x, c_end_x 191 | ) 192 | 193 | # some maps may have places where the current method 194 | # for building the climber grid isn't correct 195 | for map_exception in climber_grid_exceptions: 196 | if map_exception.lower() in map_name.lower(): 197 | for exceptions in climber_grid_exceptions[map_exception]: 198 | self.climber_grid[exceptions[0], exceptions[1]] = 0 199 | 200 | break 201 | 202 | self.overlord_spots = list(map(Point2, overlord_data)) 203 | self.chokes = [] 204 | id_counter = 0 205 | for c in choke_data: 206 | self.chokes.append( 207 | CMapChoke(id_counter, c[0], c[1], c[2], c[3], c[4], c[5]) 208 | ) 209 | id_counter += 1 210 | 211 | @staticmethod 212 | def _get_map_data( 213 | walkable_grid: np.ndarray, 214 | height_map: np.ndarray, 215 | start_y: int, 216 | end_y: int, 217 | start_x: int, 218 | end_x: int, 219 | ): 220 | height, width = walkable_grid.shape 221 | return ext_get_map_data( 222 | walkable_grid.flatten(), 223 | height_map.flatten(), 224 | height, 225 | width, 226 | start_y, 227 | end_y, 228 | start_x, 229 | end_x, 230 | ) 231 | -------------------------------------------------------------------------------- /map_analyzer/constants.py: -------------------------------------------------------------------------------- 1 | MIN_REGION_AREA = 25 2 | MAX_REGION_AREA = 8500 3 | BINARY_STRUCTURE = 2 4 | RESOURCE_BLOCKER_RADIUS_FACTOR = 2 5 | GEYSER_RADIUS_FACTOR = 1.055 6 | NONPATHABLE_RADIUS_FACTOR = 0.8 7 | CORNER_MIN_DISTANCE = 3 8 | LOG_MODULE = "map_analyzer" 9 | COLORS = { 10 | 0: "azure", 11 | 1: "black", 12 | 2: "red", 13 | 3: "green", 14 | 4: "blue", 15 | 5: "orange", 16 | 6: "saddlebrown", 17 | 7: "lime", 18 | 8: "navy", 19 | 9: "purple", 20 | 10: "olive", 21 | 11: "aquamarine", 22 | 12: "indigo", 23 | 13: "tan", 24 | 14: "skyblue", 25 | 15: "wheat", 26 | 16: "azure", 27 | 17: "azure", 28 | 18: "azure", 29 | 19: "azure", 30 | 20: "azure", 31 | } 32 | LOG_FORMAT = ( 33 | "{time:YY:MM:DD:HH:mm:ss}|" 34 | "{level: <8}|{name: ^15}|" 35 | "{function: ^15}|" 36 | "{line: >4}|" 37 | " {level.icon} {message}" 38 | ) 39 | -------------------------------------------------------------------------------- /map_analyzer/constructs.py: -------------------------------------------------------------------------------- 1 | from functools import lru_cache 2 | from typing import TYPE_CHECKING 3 | 4 | import numpy as np 5 | from loguru import logger 6 | from sc2.game_info import Ramp as sc2Ramp 7 | from sc2.position import Point2 8 | 9 | from .cext import CMapChoke 10 | from .Polygon import Polygon 11 | 12 | if TYPE_CHECKING: # pragma: no cover 13 | from .MapData import MapData 14 | 15 | 16 | class ChokeArea(Polygon): 17 | """ 18 | 19 | Base class for all chokes 20 | 21 | """ 22 | 23 | def __init__(self, array: np.ndarray, map_data: "MapData") -> None: 24 | super().__init__(map_data=map_data, array=array) 25 | self.main_line = None 26 | self.id = "Unregistered" 27 | self.md_pl_choke = None 28 | self.is_choke = True 29 | self.ramp = None 30 | self.side_a = None 31 | self.side_b = None 32 | 33 | @property 34 | def corner_walloff(self): 35 | return sorted( 36 | list(self.points), 37 | key=lambda x: x.distance_to_point2(self.center), 38 | reverse=True, 39 | )[:2] 40 | 41 | @lru_cache() 42 | def same_height(self, p1, p2): 43 | return self.map_data.terrain_height[p1] == self.map_data.terrain_height[p2] 44 | 45 | def __repr__(self) -> str: # pragma: no cover 46 | return f"<[{self.id}]ChokeArea[size={self.area}]>" 47 | 48 | 49 | class RawChoke(ChokeArea): 50 | """ 51 | Chokes found in the C extension where the terrain generates a choke point 52 | """ 53 | 54 | def __init__( 55 | self, array: np.ndarray, map_data: "MapData", raw_choke: CMapChoke 56 | ) -> None: 57 | super().__init__(map_data=map_data, array=array) 58 | 59 | self.main_line = raw_choke.main_line 60 | self.id = raw_choke.id 61 | self.md_pl_choke = raw_choke 62 | 63 | self.side_a = Point2((int(self.main_line[0][0]), int(self.main_line[0][1]))) 64 | self.side_b = Point2((int(self.main_line[1][0]), int(self.main_line[1][1]))) 65 | 66 | def __repr__(self) -> str: # pragma: no cover 67 | return f"<[{self.id}]RawChoke[size={self.area}]>" 68 | 69 | 70 | class MDRamp(ChokeArea): 71 | """ 72 | 73 | Wrapper for :class:`sc2.game_info.Ramp`, 74 | 75 | is responsible for calculating the relevant :class:`.Region` 76 | """ 77 | 78 | def __init__(self, map_data: "MapData", array: np.ndarray, ramp: sc2Ramp) -> None: 79 | super().__init__(map_data=map_data, array=array) 80 | self.is_ramp = True 81 | self.ramp = ramp 82 | self.offset = Point2((0.5, 0.5)) 83 | self._set_sides() 84 | 85 | def _set_sides(self): 86 | ramp_dir = self.ramp.bottom_center - self.ramp.top_center 87 | perpendicular_dir = Point2((-ramp_dir[1], ramp_dir[0])).normalized 88 | step_size = 1 89 | 90 | current = self.ramp.top_center.offset(ramp_dir / 2) 91 | side_a = current.rounded 92 | next_point = current.rounded 93 | while next_point in self.points: 94 | side_a = next_point 95 | current = current.offset(perpendicular_dir * step_size) 96 | next_point = current.rounded 97 | 98 | self.side_a = side_a 99 | 100 | current = self.ramp.top_center.offset(ramp_dir / 2) 101 | side_b = current.rounded 102 | next_point = current.rounded 103 | while next_point in self.points: 104 | side_b = next_point 105 | current = current.offset(-perpendicular_dir * step_size) 106 | next_point = current.rounded 107 | 108 | self.side_b = side_b 109 | 110 | @property 111 | def corner_walloff(self): 112 | raw_points = sorted( 113 | list(self.points), 114 | key=lambda x: x.distance_to_point2(self.bottom_center), 115 | reverse=True, 116 | )[:2] 117 | offset_points = [p.offset(self.offset) for p in raw_points] 118 | offset_points.extend(raw_points) 119 | return offset_points 120 | 121 | @property 122 | def middle_walloff_depot(self): 123 | raw_points = sorted( 124 | list(self.points), 125 | key=lambda x: x.distance_to_point2(self.bottom_center), 126 | reverse=True, 127 | ) 128 | # TODO its white board time, need to figure out some geometric intuition here 129 | dist = self.map_data.distance(raw_points[0], raw_points[1]) 130 | r = dist**0.5 131 | if dist / 2 >= r: 132 | intersect = (raw_points[0] + raw_points[1]) / 2 133 | return intersect.offset(self.offset) 134 | 135 | intersects = raw_points[0].circle_intersection(p=raw_points[1], r=r) 136 | pt = max(intersects, key=lambda p: p.distance_to_point2(self.bottom_center)) 137 | return pt.offset(self.offset) 138 | 139 | def closest_region(self, region_list): 140 | """ 141 | 142 | Will return the closest region with respect to self 143 | 144 | """ 145 | return min( 146 | region_list, 147 | key=lambda area: min( 148 | self.map_data.distance(area.center, point) 149 | for point in self.outer_perimeter_points 150 | ), 151 | ) 152 | 153 | def set_regions(self): 154 | """ 155 | 156 | Method for calculating the relevant :class:`.Region` 157 | 158 | TODO: 159 | Make this a private method 160 | 161 | """ 162 | from map_analyzer.Region import Region 163 | 164 | for p in self.outer_perimeter_points: 165 | areas = self.map_data.where_all(p) 166 | for area in areas: 167 | # edge case = its a VisionBlockerArea (and also on the perimeter) 168 | # so we grab the touching Regions 169 | if isinstance(area, VisionBlockerArea): 170 | for sub_area in area.areas: 171 | # add it to our Areas 172 | if isinstance(sub_area, Region) and sub_area not in self.areas: 173 | self.areas.append(sub_area) 174 | # add ourselves to it's Areas 175 | if isinstance(sub_area, Region) and self not in sub_area.areas: 176 | sub_area.areas.append(self) 177 | 178 | # standard case 179 | if isinstance(area, Region) and area not in self.areas: 180 | self.areas.append(area) 181 | # add ourselves to the Region Area's 182 | if isinstance(area, Region) and self not in area.areas: 183 | area.areas.append(self) 184 | 185 | if len(self.regions) < 2: 186 | region_list = list(self.map_data.regions.values()) 187 | region_list.remove(self.regions[0]) 188 | closest_region = self.closest_region(region_list=region_list) 189 | assert closest_region not in self.regions 190 | self.areas.append(closest_region) 191 | 192 | @property 193 | def top_center(self) -> Point2: 194 | """ 195 | 196 | Alerts when sc2 fails to provide a top_center, and fallback to :meth:`.center` 197 | 198 | """ 199 | if self.ramp.top_center is not None: 200 | return self.ramp.top_center 201 | else: 202 | logger.debug(f"No top_center found for {self}, falling back to `center`") 203 | return self.center 204 | 205 | @property 206 | def bottom_center(self) -> Point2: 207 | """ 208 | 209 | Alerts when sc2 fails to provide a bottom_center, 210 | and fallback to :meth:`.center` 211 | 212 | """ 213 | if self.ramp.bottom_center is not None: 214 | return self.ramp.bottom_center 215 | else: 216 | logger.debug(f"No bottom_center found for {self}, falling back to `center`") 217 | return self.center 218 | 219 | def __repr__(self) -> str: # pragma: no cover 220 | return f"" 221 | 222 | def __str__(self): 223 | return f"R[{self.area}]" 224 | 225 | 226 | class VisionBlockerArea(ChokeArea): 227 | """ 228 | 229 | VisionBlockerArea are areas containing tiles that hide the units that stand in it, 230 | 231 | (for example, bushes) 232 | 233 | Units that attack from within a :class:`VisionBlockerArea` 234 | 235 | cannot be targeted by units that do not stand inside 236 | """ 237 | 238 | def __init__(self, map_data: "MapData", array: np.ndarray) -> None: 239 | super().__init__(map_data=map_data, array=array) 240 | self.is_vision_blocker = True 241 | self._set_sides() 242 | 243 | def _set_sides(self): 244 | org = self.top 245 | pts = [self.bottom, self.right, self.left] 246 | res = self.map_data.closest_towards_point(points=pts, target=org) 247 | self.side_a = int(round((res[0] + org[0]) / 2)), int( 248 | round((res[1] + org[1]) / 2) 249 | ) 250 | if res != self.bottom: 251 | org = self.bottom 252 | pts = [self.top, self.right, self.left] 253 | res = self.map_data.closest_towards_point(points=pts, target=org) 254 | self.side_b = int(round((res[0] + org[0]) / 2)), int( 255 | round((res[1] + org[1]) / 2) 256 | ) 257 | else: 258 | self.side_b = int(round((self.right[0] + self.left[0]) / 2)), int( 259 | round((self.right[1] + self.left[1]) / 2) 260 | ) 261 | 262 | def __repr__(self): # pragma: no cover 263 | return f"" 264 | -------------------------------------------------------------------------------- /map_analyzer/destructibles.py: -------------------------------------------------------------------------------- 1 | """ 2 | https://github.com/DrInfy/sharpy-sc2/blob/develop/sharpy/managers/unit_value.py 3 | """ 4 | from sc2.ids.unit_typeid import UnitTypeId 5 | 6 | buildings_2x2 = { 7 | UnitTypeId.SUPPLYDEPOT, 8 | UnitTypeId.PYLON, 9 | UnitTypeId.DARKSHRINE, 10 | UnitTypeId.PHOTONCANNON, 11 | UnitTypeId.SHIELDBATTERY, 12 | UnitTypeId.TECHLAB, 13 | UnitTypeId.STARPORTTECHLAB, 14 | UnitTypeId.FACTORYTECHLAB, 15 | UnitTypeId.BARRACKSTECHLAB, 16 | UnitTypeId.REACTOR, 17 | UnitTypeId.STARPORTREACTOR, 18 | UnitTypeId.FACTORYREACTOR, 19 | UnitTypeId.BARRACKSREACTOR, 20 | UnitTypeId.MISSILETURRET, 21 | UnitTypeId.SPORECRAWLER, 22 | UnitTypeId.SPIRE, 23 | UnitTypeId.GREATERSPIRE, 24 | UnitTypeId.SPINECRAWLER, 25 | } 26 | 27 | buildings_3x3 = { 28 | UnitTypeId.GATEWAY, 29 | UnitTypeId.WARPGATE, 30 | UnitTypeId.CYBERNETICSCORE, 31 | UnitTypeId.FORGE, 32 | UnitTypeId.ROBOTICSFACILITY, 33 | UnitTypeId.ROBOTICSBAY, 34 | UnitTypeId.TEMPLARARCHIVE, 35 | UnitTypeId.TWILIGHTCOUNCIL, 36 | UnitTypeId.TEMPLARARCHIVE, 37 | UnitTypeId.STARGATE, 38 | UnitTypeId.FLEETBEACON, 39 | UnitTypeId.ASSIMILATOR, 40 | UnitTypeId.ASSIMILATORRICH, 41 | UnitTypeId.SPAWNINGPOOL, 42 | UnitTypeId.ROACHWARREN, 43 | UnitTypeId.HYDRALISKDEN, 44 | UnitTypeId.BANELINGNEST, 45 | UnitTypeId.EVOLUTIONCHAMBER, 46 | UnitTypeId.NYDUSNETWORK, 47 | UnitTypeId.NYDUSCANAL, 48 | UnitTypeId.EXTRACTOR, 49 | UnitTypeId.EXTRACTORRICH, 50 | UnitTypeId.INFESTATIONPIT, 51 | UnitTypeId.ULTRALISKCAVERN, 52 | UnitTypeId.BARRACKS, 53 | UnitTypeId.ENGINEERINGBAY, 54 | UnitTypeId.FACTORY, 55 | UnitTypeId.GHOSTACADEMY, 56 | UnitTypeId.STARPORT, 57 | UnitTypeId.FUSIONREACTOR, 58 | UnitTypeId.BUNKER, 59 | UnitTypeId.ARMORY, 60 | UnitTypeId.REFINERY, 61 | UnitTypeId.REFINERYRICH, 62 | } 63 | 64 | buildings_5x5 = { 65 | UnitTypeId.NEXUS, 66 | UnitTypeId.HATCHERY, 67 | UnitTypeId.HIVE, 68 | UnitTypeId.LAIR, 69 | UnitTypeId.COMMANDCENTER, 70 | UnitTypeId.ORBITALCOMMAND, 71 | UnitTypeId.PLANETARYFORTRESS, 72 | } 73 | 74 | BUILDING_IDS = buildings_5x5.union(buildings_3x3).union(buildings_2x2) 75 | 76 | 77 | destructable_2x2 = {UnitTypeId.ROCKS2X2NONCONJOINED, UnitTypeId.DEBRIS2X2NONCONJOINED} 78 | 79 | destructable_4x4 = { 80 | UnitTypeId.DESTRUCTIBLECITYDEBRIS4X4, 81 | UnitTypeId.DESTRUCTIBLEDEBRIS4X4, 82 | UnitTypeId.DESTRUCTIBLEICE4X4, 83 | UnitTypeId.DESTRUCTIBLEROCK4X4, 84 | UnitTypeId.DESTRUCTIBLEROCKEX14X4, 85 | } 86 | 87 | destructable_4x2 = { 88 | UnitTypeId.DESTRUCTIBLECITYDEBRIS2X4HORIZONTAL, 89 | UnitTypeId.DESTRUCTIBLEICE2X4HORIZONTAL, 90 | UnitTypeId.DESTRUCTIBLEROCK2X4HORIZONTAL, 91 | UnitTypeId.DESTRUCTIBLEROCKEX12X4HORIZONTAL, 92 | } 93 | 94 | destructable_2x4 = { 95 | UnitTypeId.DESTRUCTIBLECITYDEBRIS2X4VERTICAL, 96 | UnitTypeId.DESTRUCTIBLEICE2X4VERTICAL, 97 | UnitTypeId.DESTRUCTIBLEROCK2X4VERTICAL, 98 | UnitTypeId.DESTRUCTIBLEROCKEX12X4VERTICAL, 99 | } 100 | 101 | destructable_6x2 = { 102 | UnitTypeId.DESTRUCTIBLECITYDEBRIS2X6HORIZONTAL, 103 | UnitTypeId.DESTRUCTIBLEICE2X6HORIZONTAL, 104 | UnitTypeId.DESTRUCTIBLEROCK2X6HORIZONTAL, 105 | UnitTypeId.DESTRUCTIBLEROCKEX12X6HORIZONTAL, 106 | } 107 | 108 | destructable_2x6 = { 109 | UnitTypeId.DESTRUCTIBLECITYDEBRIS2X6VERTICAL, 110 | UnitTypeId.DESTRUCTIBLEICE2X6VERTICAL, 111 | UnitTypeId.DESTRUCTIBLEROCK2X6VERTICAL, 112 | UnitTypeId.DESTRUCTIBLEROCKEX12X6VERTICAL, 113 | } 114 | 115 | destructable_4x12 = { 116 | UnitTypeId.DESTRUCTIBLEROCKEX1VERTICALHUGE, 117 | UnitTypeId.DESTRUCTIBLEICEVERTICALHUGE, 118 | } 119 | 120 | destructable_12x4 = { 121 | UnitTypeId.DESTRUCTIBLEROCKEX1HORIZONTALHUGE, 122 | UnitTypeId.DESTRUCTIBLEICEHORIZONTALHUGE, 123 | } 124 | 125 | destructable_6x6 = { 126 | UnitTypeId.DESTRUCTIBLECITYDEBRIS6X6, 127 | UnitTypeId.DESTRUCTIBLEDEBRIS6X6, 128 | UnitTypeId.DESTRUCTIBLEICE6X6, 129 | UnitTypeId.DESTRUCTIBLEROCK6X6, 130 | UnitTypeId.DESTRUCTIBLEROCKEX16X6, 131 | } 132 | 133 | destructable_BLUR = { 134 | UnitTypeId.DESTRUCTIBLECITYDEBRISHUGEDIAGONALBLUR, 135 | UnitTypeId.DESTRUCTIBLEDEBRISRAMPDIAGONALHUGEBLUR, 136 | UnitTypeId.DESTRUCTIBLEICEDIAGONALHUGEBLUR, 137 | UnitTypeId.DESTRUCTIBLEROCKEX1DIAGONALHUGEBLUR, 138 | UnitTypeId.DESTRUCTIBLERAMPDIAGONALHUGEBLUR, 139 | } 140 | 141 | destructable_ULBR = { 142 | UnitTypeId.DESTRUCTIBLECITYDEBRISHUGEDIAGONALULBR, 143 | UnitTypeId.DESTRUCTIBLEDEBRISRAMPDIAGONALHUGEULBR, 144 | UnitTypeId.DESTRUCTIBLEICEDIAGONALHUGEULBR, 145 | UnitTypeId.DESTRUCTIBLEROCKEX1DIAGONALHUGEULBR, 146 | UnitTypeId.DESTRUCTIBLERAMPDIAGONALHUGEULBR, 147 | } 148 | -------------------------------------------------------------------------------- /map_analyzer/exceptions.py: -------------------------------------------------------------------------------- 1 | from typing import Tuple 2 | 3 | 4 | class CustomDeprecationWarning(BaseException): 5 | def __init__(self, oldarg=None, newarg=None): 6 | self.old = oldarg 7 | self.new = newarg 8 | 9 | def __str__(self) -> str: 10 | return ( 11 | f"[DeprecationWarning] Passing `{self.old}` argument is deprecated," 12 | f" and will have no effect,\nUse `{self.new}` instead" 13 | ) 14 | 15 | 16 | class PatherNoPointsException(BaseException): 17 | def __init__(self, start, goal) -> None: 18 | super().__init__() 19 | self.start = start 20 | self.goal = goal 21 | 22 | def __str__(self) -> str: 23 | return ( 24 | f"[PatherNoPointsException]" 25 | f"\nExpected: Start (pointlike), Goal (pointlike)," 26 | f"\nGot: Start {self.start}, Goal {self.goal}." 27 | ) 28 | 29 | 30 | class OutOfBoundsException(BaseException): 31 | def __init__(self, p: Tuple[int, int]) -> None: 32 | super().__init__() 33 | self.point = p 34 | 35 | def __str__(self) -> str: 36 | return ( 37 | f"[OutOfBoundsException]Point {self.point} " 38 | f"is not inside the grid. No influence added." 39 | ) 40 | -------------------------------------------------------------------------------- /map_analyzer/pickle_gameinfo/2000AtmospheresAIE.xz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spudde123/SC2MapAnalysis/1e76decb33b9c622f90ef7e91f60ae652b25c8a7/map_analyzer/pickle_gameinfo/2000AtmospheresAIE.xz -------------------------------------------------------------------------------- /map_analyzer/pickle_gameinfo/AbyssalReefLE.xz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spudde123/SC2MapAnalysis/1e76decb33b9c622f90ef7e91f60ae652b25c8a7/map_analyzer/pickle_gameinfo/AbyssalReefLE.xz -------------------------------------------------------------------------------- /map_analyzer/pickle_gameinfo/AncientCisternAIE.xz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spudde123/SC2MapAnalysis/1e76decb33b9c622f90ef7e91f60ae652b25c8a7/map_analyzer/pickle_gameinfo/AncientCisternAIE.xz -------------------------------------------------------------------------------- /map_analyzer/pickle_gameinfo/BerlingradAIE.xz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spudde123/SC2MapAnalysis/1e76decb33b9c622f90ef7e91f60ae652b25c8a7/map_analyzer/pickle_gameinfo/BerlingradAIE.xz -------------------------------------------------------------------------------- /map_analyzer/pickle_gameinfo/BlackburnAIE.xz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spudde123/SC2MapAnalysis/1e76decb33b9c622f90ef7e91f60ae652b25c8a7/map_analyzer/pickle_gameinfo/BlackburnAIE.xz -------------------------------------------------------------------------------- /map_analyzer/pickle_gameinfo/CuriousMindsAIE.xz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spudde123/SC2MapAnalysis/1e76decb33b9c622f90ef7e91f60ae652b25c8a7/map_analyzer/pickle_gameinfo/CuriousMindsAIE.xz -------------------------------------------------------------------------------- /map_analyzer/pickle_gameinfo/DeathAuraLE.xz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spudde123/SC2MapAnalysis/1e76decb33b9c622f90ef7e91f60ae652b25c8a7/map_analyzer/pickle_gameinfo/DeathAuraLE.xz -------------------------------------------------------------------------------- /map_analyzer/pickle_gameinfo/EternalEmpireLE.xz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spudde123/SC2MapAnalysis/1e76decb33b9c622f90ef7e91f60ae652b25c8a7/map_analyzer/pickle_gameinfo/EternalEmpireLE.xz -------------------------------------------------------------------------------- /map_analyzer/pickle_gameinfo/GlitteringAshesAIE.xz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spudde123/SC2MapAnalysis/1e76decb33b9c622f90ef7e91f60ae652b25c8a7/map_analyzer/pickle_gameinfo/GlitteringAshesAIE.xz -------------------------------------------------------------------------------- /map_analyzer/pickle_gameinfo/GoldenWallLE.xz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spudde123/SC2MapAnalysis/1e76decb33b9c622f90ef7e91f60ae652b25c8a7/map_analyzer/pickle_gameinfo/GoldenWallLE.xz -------------------------------------------------------------------------------- /map_analyzer/pickle_gameinfo/GoldenauraAIE.xz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spudde123/SC2MapAnalysis/1e76decb33b9c622f90ef7e91f60ae652b25c8a7/map_analyzer/pickle_gameinfo/GoldenauraAIE.xz -------------------------------------------------------------------------------- /map_analyzer/pickle_gameinfo/HardwireAIE.xz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spudde123/SC2MapAnalysis/1e76decb33b9c622f90ef7e91f60ae652b25c8a7/map_analyzer/pickle_gameinfo/HardwireAIE.xz -------------------------------------------------------------------------------- /map_analyzer/pickle_gameinfo/IceandChromeLE.xz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spudde123/SC2MapAnalysis/1e76decb33b9c622f90ef7e91f60ae652b25c8a7/map_analyzer/pickle_gameinfo/IceandChromeLE.xz -------------------------------------------------------------------------------- /map_analyzer/pickle_gameinfo/InfestationStationAIE.xz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spudde123/SC2MapAnalysis/1e76decb33b9c622f90ef7e91f60ae652b25c8a7/map_analyzer/pickle_gameinfo/InfestationStationAIE.xz -------------------------------------------------------------------------------- /map_analyzer/pickle_gameinfo/JagannathaAIE.xz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spudde123/SC2MapAnalysis/1e76decb33b9c622f90ef7e91f60ae652b25c8a7/map_analyzer/pickle_gameinfo/JagannathaAIE.xz -------------------------------------------------------------------------------- /map_analyzer/pickle_gameinfo/LightshadeAIE.xz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spudde123/SC2MapAnalysis/1e76decb33b9c622f90ef7e91f60ae652b25c8a7/map_analyzer/pickle_gameinfo/LightshadeAIE.xz -------------------------------------------------------------------------------- /map_analyzer/pickle_gameinfo/NightshadeLE.xz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spudde123/SC2MapAnalysis/1e76decb33b9c622f90ef7e91f60ae652b25c8a7/map_analyzer/pickle_gameinfo/NightshadeLE.xz -------------------------------------------------------------------------------- /map_analyzer/pickle_gameinfo/PillarsofGoldLE.xz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spudde123/SC2MapAnalysis/1e76decb33b9c622f90ef7e91f60ae652b25c8a7/map_analyzer/pickle_gameinfo/PillarsofGoldLE.xz -------------------------------------------------------------------------------- /map_analyzer/pickle_gameinfo/RomanticideAIE.xz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spudde123/SC2MapAnalysis/1e76decb33b9c622f90ef7e91f60ae652b25c8a7/map_analyzer/pickle_gameinfo/RomanticideAIE.xz -------------------------------------------------------------------------------- /map_analyzer/pickle_gameinfo/RoyalBloodAIE.xz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spudde123/SC2MapAnalysis/1e76decb33b9c622f90ef7e91f60ae652b25c8a7/map_analyzer/pickle_gameinfo/RoyalBloodAIE.xz -------------------------------------------------------------------------------- /map_analyzer/pickle_gameinfo/SimulacrumLE.xz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spudde123/SC2MapAnalysis/1e76decb33b9c622f90ef7e91f60ae652b25c8a7/map_analyzer/pickle_gameinfo/SimulacrumLE.xz -------------------------------------------------------------------------------- /map_analyzer/settings.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | ROOT_DIR = os.path.dirname(os.path.abspath(__file__)) 4 | -------------------------------------------------------------------------------- /map_analyzer/utils.py: -------------------------------------------------------------------------------- 1 | import lzma 2 | import os 3 | import pickle 4 | from typing import TYPE_CHECKING, List, Optional, Union 5 | 6 | import numpy as np 7 | from s2clientprotocol.sc2api_pb2 import Response, ResponseObservation 8 | from sc2.bot_ai import BotAI 9 | from sc2.game_data import GameData 10 | from sc2.game_info import GameInfo, Ramp 11 | from sc2.game_state import GameState 12 | from sc2.position import Point2 13 | from sc2.unit import Unit 14 | 15 | from map_analyzer.constructs import MDRamp, VisionBlockerArea 16 | 17 | from .cext import CMapChoke 18 | from .destructibles import ( 19 | destructable_2x2, 20 | destructable_2x4, 21 | destructable_2x6, 22 | destructable_4x2, 23 | destructable_4x4, 24 | destructable_4x12, 25 | destructable_6x2, 26 | destructable_6x6, 27 | destructable_12x4, 28 | destructable_BLUR, 29 | destructable_ULBR, 30 | ) 31 | from .settings import ROOT_DIR 32 | 33 | if TYPE_CHECKING: 34 | from map_analyzer.MapData import MapData 35 | 36 | 37 | def change_destructable_status_in_grid(grid: np.ndarray, unit: Unit, status: int): 38 | """ 39 | Set destructable positions to status, modifies the grid in place 40 | """ 41 | type_id = unit.type_id 42 | pos = unit.position 43 | name = unit.name 44 | 45 | # this is checked with name because the id of the small mineral destructables 46 | # has changed over patches and may cause problems 47 | if name == "MineralField450": 48 | x = int(pos[0]) - 1 49 | y = int(pos[1]) 50 | grid[x : (x + 2), y] = status 51 | elif type_id in destructable_2x2: 52 | w = 2 53 | h = 2 54 | x = int(pos[0] - w / 2) 55 | y = int(pos[1] - h / 2) 56 | grid[x : (x + w), y : (y + h)] = status 57 | elif type_id in destructable_2x4: 58 | w = 2 59 | h = 4 60 | x = int(pos[0] - w / 2) 61 | y = int(pos[1] - h / 2) 62 | grid[x : (x + w), y : (y + h)] = status 63 | elif type_id in destructable_2x6: 64 | w = 2 65 | h = 6 66 | x = int(pos[0] - w / 2) 67 | y = int(pos[1] - h / 2) 68 | grid[x : (x + w), y : (y + h)] = status 69 | elif type_id in destructable_4x2: 70 | w = 4 71 | h = 2 72 | x = int(pos[0] - w / 2) 73 | y = int(pos[1] - h / 2) 74 | grid[x : (x + w), y : (y + h)] = status 75 | elif type_id in destructable_4x4: 76 | w = 4 77 | h = 4 78 | x = int(pos[0] - w / 2) 79 | y = int(pos[1] - h / 2) 80 | grid[x : (x + w), y : (y + h)] = status 81 | elif type_id in destructable_6x2: 82 | w = 6 83 | h = 2 84 | x = int(pos[0] - w / 2) 85 | y = int(pos[1] - h / 2) 86 | grid[x : (x + w), y : (y + h)] = status 87 | elif type_id in destructable_6x6: 88 | # for some reason on some maps like death aura 89 | # these rocks have a bit weird sizes 90 | # on golden wall this is an exact match 91 | # on death aura the height is one coordinate off 92 | # and it varies whether the position is centered too high or too low 93 | w = 6 94 | h = 6 95 | x = int(pos[0] - w / 2) 96 | y = int(pos[1] - h / 2) 97 | grid[x : (x + w), (y + 1) : (y + h - 1)] = status 98 | grid[(x + 1) : (x + w - 1), y : (y + h)] = status 99 | elif type_id in destructable_12x4: 100 | w = 12 101 | h = 4 102 | x = int(pos[0] - w / 2) 103 | y = int(pos[1] - h / 2) 104 | grid[x : (x + w), y : (y + h)] = status 105 | elif type_id in destructable_4x12: 106 | w = 4 107 | h = 12 108 | x = int(pos[0] - w / 2) 109 | y = int(pos[1] - h / 2) 110 | grid[x : (x + w), y : (y + h)] = status 111 | elif type_id in destructable_BLUR: 112 | x_ref = int(pos[0] - 5) 113 | y_pos = int(pos[1]) 114 | grid[(x_ref + 6) : (x_ref + 6 + 2), y_pos + 4] = status 115 | grid[(x_ref + 5) : (x_ref + 5 + 4), y_pos + 3] = status 116 | grid[(x_ref + 4) : (x_ref + 4 + 6), y_pos + 2] = status 117 | grid[(x_ref + 3) : (x_ref + 3 + 7), y_pos + 1] = status 118 | grid[(x_ref + 2) : (x_ref + 2 + 7), y_pos] = status 119 | grid[(x_ref + 1) : (x_ref + 1 + 7), y_pos - 1] = status 120 | grid[(x_ref + 0) : (x_ref + 0 + 7), y_pos - 2] = status 121 | grid[(x_ref + 0) : (x_ref + 0 + 6), y_pos - 3] = status 122 | grid[(x_ref + 1) : (x_ref + 1 + 4), y_pos - 4] = status 123 | grid[(x_ref + 2) : (x_ref + 2 + 2), y_pos - 5] = status 124 | 125 | elif type_id in destructable_ULBR: 126 | x_ref = int(pos[0] - 5) 127 | y_pos = int(pos[1]) 128 | grid[(x_ref + 6) : (x_ref + 6 + 2), y_pos - 5] = status 129 | grid[(x_ref + 5) : (x_ref + 5 + 4), y_pos - 4] = status 130 | grid[(x_ref + 4) : (x_ref + 4 + 6), y_pos - 3] = status 131 | grid[(x_ref + 3) : (x_ref + 3 + 7), y_pos - 2] = status 132 | grid[(x_ref + 2) : (x_ref + 2 + 7), y_pos - 1] = status 133 | grid[(x_ref + 1) : (x_ref + 1 + 7), y_pos] = status 134 | grid[(x_ref + 0) : (x_ref + 0 + 7), y_pos + 1] = status 135 | grid[(x_ref + 0) : (x_ref + 0 + 6), y_pos + 2] = status 136 | grid[(x_ref + 1) : (x_ref + 1 + 4), y_pos + 3] = status 137 | grid[(x_ref + 2) : (x_ref + 2 + 2), y_pos + 4] = status 138 | 139 | 140 | def fix_map_ramps(bot: BotAI): 141 | """ 142 | following 143 | https://github.com/BurnySc2/python-sc2/blob/ 144 | ffb9bd43dcbeb923d848558945a8c59c9662f435/sc2/game_info.py#L246 145 | to fix burnysc2 ramp objects by removing destructables 146 | """ 147 | pathing_grid = bot.game_info.pathing_grid.data_numpy.T.copy() 148 | for dest in bot.destructables: 149 | change_destructable_status_in_grid(pathing_grid, dest, 1) 150 | 151 | pathing = np.ndenumerate(pathing_grid.T) 152 | 153 | def equal_height_around(tile): 154 | sliced = bot.game_info.terrain_height.data_numpy[ 155 | tile[1] - 1 : tile[1] + 2, tile[0] - 1 : tile[0] + 2 156 | ] 157 | return len(np.unique(sliced)) == 1 158 | 159 | map_area = bot.game_info.playable_area 160 | points = [ 161 | Point2((a, b)) 162 | for (b, a), value in pathing 163 | if value == 1 164 | and map_area.x <= a < map_area.x + map_area.width 165 | and map_area.y <= b < map_area.y + map_area.height 166 | and bot.game_info.placement_grid[(a, b)] == 0 167 | ] 168 | ramp_points = [point for point in points if not equal_height_around(point)] 169 | vision_blockers = set(point for point in points if equal_height_around(point)) 170 | ramps = [ 171 | Ramp(group, bot.game_info) for group in bot.game_info._find_groups(ramp_points) 172 | ] 173 | return ramps, vision_blockers 174 | 175 | 176 | def get_sets_with_mutual_elements( 177 | list_mdchokes: List[CMapChoke], 178 | area: Optional[Union[MDRamp, VisionBlockerArea]] = None, 179 | base_choke: CMapChoke = None, 180 | ) -> List[int]: 181 | li = [] 182 | if area: 183 | s1 = area.points 184 | else: 185 | s1 = base_choke.pixels 186 | for c in list_mdchokes: 187 | s2 = c.pixels 188 | s3 = s1 ^ s2 189 | if len(s3) <= 0.95 * (len(s1) + len(s2)): 190 | li.append(c.id) 191 | return li 192 | 193 | 194 | def mock_map_data(map_file: str) -> "MapData": 195 | from map_analyzer.MapData import MapData 196 | 197 | with lzma.open(f"{map_file}", "rb") as f: 198 | raw_game_data, raw_game_info, raw_observation = pickle.load(f) 199 | 200 | bot = import_bot_instance(raw_game_data, raw_game_info, raw_observation) 201 | return MapData(bot=bot) 202 | 203 | 204 | def import_bot_instance( 205 | raw_game_data: Response, 206 | raw_game_info: Response, 207 | raw_observation: ResponseObservation, 208 | ) -> BotAI: 209 | """ 210 | import_bot_instance DocString 211 | """ 212 | bot = BotAI() 213 | game_data = GameData(raw_game_data.data) 214 | game_info = GameInfo(raw_game_info.game_info) 215 | game_state = GameState(raw_observation) 216 | # noinspection PyProtectedMember 217 | bot._initialize_variables() 218 | # noinspection PyProtectedMember 219 | bot._prepare_start( 220 | client=None, player_id=1, game_info=game_info, game_data=game_data 221 | ) 222 | # noinspection PyProtectedMember 223 | bot._prepare_first_step() 224 | # noinspection PyProtectedMember 225 | bot._prepare_step(state=game_state, proto_game_info=raw_game_info) 226 | # noinspection PyProtectedMember 227 | bot._find_expansion_locations() 228 | return bot 229 | 230 | 231 | def get_map_files_folder() -> str: 232 | folder = ROOT_DIR 233 | subfolder = "pickle_gameinfo" 234 | return os.path.join(folder, subfolder) 235 | 236 | 237 | def get_map_file_list() -> List[str]: 238 | """ 239 | easy way to produce less than all maps, 240 | for example if we want to test utils, we only need one MapData object 241 | """ 242 | map_files_folder = get_map_files_folder() 243 | map_files = os.listdir(map_files_folder) 244 | li = [] 245 | for map_file in map_files: 246 | li.append(os.path.join(map_files_folder, map_file)) 247 | return li 248 | -------------------------------------------------------------------------------- /monkeytest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | pytest.main() 4 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sc2mapanalyzer", 3 | "version": "0.0.87", 4 | "dependencies": { 5 | "update": "^0.7.4" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /pf_perf.py: -------------------------------------------------------------------------------- 1 | import lzma 2 | import os 3 | import pickle 4 | import random 5 | import time 6 | from typing import List 7 | 8 | from map_analyzer.MapData import MapData 9 | from map_analyzer.utils import import_bot_instance 10 | 11 | 12 | def get_random_point(minx, maxx, miny, maxy): 13 | return (random.randint(minx, maxx), random.randint(miny, maxy)) 14 | 15 | 16 | def get_map_file_list() -> List[str]: 17 | """ 18 | easy way to produce less than all maps, 19 | for example if we want to test utils, we only need one MapData object 20 | """ 21 | subfolder = "map_analyzer" 22 | subfolder2 = "pickle_gameinfo" 23 | subfolder = os.path.join(subfolder, subfolder2) 24 | folder = os.path.abspath(".") 25 | map_files_folder = os.path.join(folder, subfolder) 26 | map_files = os.listdir(map_files_folder) 27 | li = [] 28 | for map_file in map_files: 29 | li.append(os.path.join(map_files_folder, map_file)) 30 | return li 31 | 32 | 33 | map_files = get_map_file_list() 34 | map_file = "" 35 | for mf in map_files: 36 | if "goldenwall" in mf.lower(): 37 | map_file = mf 38 | break 39 | 40 | with lzma.open(map_file, "rb") as f: 41 | raw_game_data, raw_game_info, raw_observation = pickle.load(f) 42 | 43 | bot = import_bot_instance(raw_game_data, raw_game_info, raw_observation) 44 | map_data = MapData(bot, loglevel="DEBUG") 45 | 46 | base = map_data.bot.townhalls[0] 47 | reg_start = map_data.where(base.position_tuple) 48 | reg_end = map_data.where(map_data.bot.enemy_start_locations[0].position) 49 | p0 = reg_start.center 50 | p1 = reg_end.center 51 | pts = [] 52 | r = 10 53 | for i in range(50): 54 | pts.append(get_random_point(0, 200, 0, 200)) 55 | 56 | arr = map_data.get_pyastar_grid(100) 57 | for p in pts: 58 | arr = map_data.add_cost(p, r, arr) 59 | 60 | start = time.perf_counter() 61 | path2 = map_data.pathfind(p0, p1, grid=arr) 62 | ext_time = time.perf_counter() - start 63 | print("extension astar time: {}".format(ext_time)) 64 | 65 | start = time.perf_counter() 66 | nydus_path = map_data.pathfind_with_nyduses(p0, p1, grid=arr) 67 | nydus_time = time.perf_counter() - start 68 | print("nydus astar time: {}".format(nydus_time)) 69 | print("compare to without nydus: {}".format(nydus_time / ext_time)) 70 | 71 | map_data.plot_influenced_path(start=p0, goal=p1, weight_array=arr) 72 | map_data.plot_influenced_path_nydus(start=p0, goal=p1, weight_array=arr) 73 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "map_analyzer" 3 | version = "0.2.0" 4 | description = "" 5 | authors = ["Your Name "] 6 | license = "GNU" 7 | readme = "README.md" 8 | 9 | [tool.poetry.build] 10 | script = "build.py" 11 | generate-setup-file = true 12 | 13 | [tool.poetry.dependencies] 14 | python = ">=3.11,<3.12" 15 | numpy = "^1.25.2" 16 | 17 | [tool.poetry.group.dev] 18 | optional = true 19 | 20 | [tool.poetry.group.semver] 21 | optional = true 22 | 23 | [tool.poetry.group.dev.dependencies] 24 | pytest = "^7.4.0" 25 | hypothesis = "^6.82.7" 26 | pytest-cov = "^4.1.0" 27 | matplotlib = "^3.7.2" 28 | click = "^8.1.7" 29 | pytest-html = "^3.2.0" 30 | monkeytype = "^23.3.0" 31 | pytest-benchmark = "^4.0.0" 32 | coverage = "^7.3.0" 33 | codecov = "^2.1.13" 34 | scikit-image = "^0.21.0" 35 | isort = "^5.12.0" 36 | black = "^23.7.0" 37 | flake8 = "^6.1.0" 38 | burnysc2 = "^6.4.0" 39 | 40 | 41 | [tool.poetry.group.semver.dependencies] 42 | python-semantic-release = "7.33.0" 43 | 44 | [build-system] 45 | requires = ["poetry-core", "numpy", "setuptools"] 46 | build-backend = "poetry.core.masonry.api" 47 | 48 | [tool.isort] 49 | profile = "black" 50 | 51 | [tool.black] 52 | line-length = 88 53 | 54 | [tool.semantic_release] 55 | branch = "master" 56 | version_variable = "map_analyzer/__init__.py:__version__" 57 | version_toml = "pyproject.toml:tool.poetry.version" 58 | version_source = "tag" 59 | commit_version_number = true # required for version_source = "tag" 60 | tag_commit = true 61 | # might want these true later 62 | upload_to_pypi = false 63 | upload_to_release = false 64 | hvcs = "github" 65 | commit_message = "{version} [skip ci]" # skip triggering ci pipelines for version commits 66 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | log_cli = True 3 | log_cli_level = INFO 4 | addopts = --ignore=examples -p no:warnings --cov=map_analyzer --hypothesis-show-statistics --durations=0 5 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spudde123/SC2MapAnalysis/1e76decb33b9c622f90ef7e91f60ae652b25c8a7/tests/__init__.py -------------------------------------------------------------------------------- /tests/mocksetup.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | import random 4 | from typing import Iterable, Tuple 5 | 6 | import pytest 7 | from _pytest.logging import caplog as _caplog 8 | from loguru import logger 9 | 10 | from map_analyzer.MapData import MapData 11 | from map_analyzer.utils import mock_map_data 12 | 13 | # for merging pr from forks, 14 | # git push : 15 | # pytest -v --disable-warnings 16 | # mutmut run --paths-to-mutate test_suite.py --runner pytest 17 | # radon cc . -a -nb (will dump only complexity score of B and below) 18 | # monkeytype run monkeytest.py 19 | # monkeytype list-modules 20 | # mutmut run --paths-to-mutate map_analyzer/MapData.py 21 | 22 | 23 | def get_random_point(minx: int, maxx: int, miny: int, maxy: int) -> Tuple[int, int]: 24 | return random.randint(minx, maxx), random.randint(miny, maxy) 25 | 26 | 27 | @pytest.fixture 28 | def caplog(_caplog=_caplog): 29 | class PropogateHandler(logging.Handler): 30 | def emit(self, record): 31 | logging.getLogger(record.name).handle(record) 32 | 33 | handler_id = logger.add(PropogateHandler(), format="{message}") 34 | yield _caplog 35 | logger.remove(handler_id) 36 | 37 | 38 | def get_map_datas() -> Iterable[MapData]: 39 | subfolder = "map_analyzer" 40 | subfolder2 = "pickle_gameinfo" 41 | subfolder = os.path.join(subfolder, subfolder2) 42 | if "tests" in os.path.abspath("."): 43 | folder = os.path.dirname(os.path.abspath(".")) 44 | else: 45 | folder = os.path.abspath(".") 46 | map_files_folder = os.path.join(folder, subfolder) 47 | map_files = os.listdir(map_files_folder) 48 | for map_file in map_files: 49 | # EphemeronLE has a ramp with 3 regions which causes a test failure 50 | # https://github.com/eladyaniv01/SC2MapAnalysis/issues/110 51 | if "ephemeron" not in map_file.lower(): 52 | yield mock_map_data(map_file=os.path.join(map_files_folder, map_file)) 53 | -------------------------------------------------------------------------------- /tests/monkeytest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | pytest.main() 4 | -------------------------------------------------------------------------------- /tests/pathing_grid.txt: -------------------------------------------------------------------------------- 1 | 000000000000000000000000000000000000000000 2 | 000000000000000000000000000000000000000000 3 | 000000000000000000000000000000000000000000 4 | 000111111111111111111111111111111111111000 5 | 000111111111111111111111111111111111111000 6 | 000111111111111111111111111111111111111000 7 | 000111111111111111111111111111111111111000 8 | 000111111111111111111111111111111111111000 9 | 000111111111111111111111111111111111111000 10 | 000111111111111111111111111111111111111000 11 | 000111111111111111111111111111111111111000 12 | 000111111111111111111111111111111111111000 13 | 000111111111000000000000000000000000000000 14 | 000111111111000000000000000000000000000000 15 | 000111111111000000000000000000000000000000 16 | 000111111111000000000000000000000000000000 17 | 000111111111000000000000000000000000000000 18 | 000111111111000000000000000000000000000000 19 | 000111111111000000000000000000000000000000 20 | 000111111111000000000000000000000000000000 21 | 000111111111000000000000000000000000000000 22 | 000111111111000000000000000000000000000000 23 | 000111111111000000000000000000000000000000 24 | 000111111111111111111111111111111111111000 25 | 000111111111111111111111111111111111111000 26 | 000111111111000000000000000000000000011000 27 | 000111111111000000000000000000000000011000 28 | 000111111111000000000000000000000000011000 29 | 000111111111000000000000000000000000011000 30 | 000111111111000000000000000000000000011000 31 | 000111111111000000000000000000000000011000 32 | 000111111111111111111111111111111111111000 33 | 000111111111111111111111111111111111111000 34 | 000111111111111111111111111111111111111000 35 | 000111111111111111111111111111111111111000 36 | 000111111111111111111111111111111111111000 37 | 000000000000000000000000000000000000000000 38 | 000000000000000000000000000000000000000000 39 | 000000000000000000000000000000000000000000 -------------------------------------------------------------------------------- /tests/test_c_extension.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import numpy as np 4 | from sc2.position import Point2, Rect 5 | 6 | from map_analyzer.cext import CMapInfo, astar_path, astar_path_with_nyduses 7 | 8 | 9 | def load_pathing_grid(file_name): 10 | file = open(file_name, "r") 11 | lines = file.readlines() 12 | 13 | h = len(lines) 14 | w = len(lines[0]) - 1 15 | 16 | res = np.zeros((h, w)) 17 | y = 0 18 | for line in lines: 19 | x = 0 20 | for char in line: 21 | if char == "\n": 22 | continue 23 | num = int(char) 24 | if num == 1: 25 | res[y, x] = 1 26 | x += 1 27 | y += 1 28 | file.close() 29 | 30 | return res.astype(np.uint8) 31 | 32 | 33 | def test_c_extension(): 34 | script_dir = os.path.dirname(__file__) 35 | abs_file_path = os.path.join(script_dir, "pathing_grid.txt") 36 | walkable_grid = load_pathing_grid(abs_file_path) 37 | 38 | pathing_grid = np.where(walkable_grid == 0, np.inf, walkable_grid).astype( 39 | np.float32 40 | ) 41 | path = astar_path(pathing_grid, (3, 3), (33, 38), False, False) 42 | assert path is not None and path.shape[0] == 56 43 | 44 | influenced_grid = pathing_grid.copy() 45 | influenced_grid[21:28, 5:20] = 100 46 | path2 = astar_path(influenced_grid, (3, 3), (33, 38), False, False) 47 | 48 | assert path2 is not None and path2.shape[0] == 59 49 | 50 | paths_no_nydus = astar_path_with_nyduses( 51 | influenced_grid, (3, 3), (33, 38), [], False, False 52 | ) 53 | 54 | assert paths_no_nydus is not None and paths_no_nydus[0].shape[0] == 59 55 | 56 | nydus_positions = [Point2((6.5, 6.5)), Point2((29.5, 34.5))] 57 | 58 | influenced_grid[5:8, 5:8] = np.inf 59 | influenced_grid[28:31, 33:36] = np.inf 60 | 61 | paths_nydus = astar_path_with_nyduses( 62 | influenced_grid, (3, 3), (33, 38), nydus_positions, False, False 63 | ) 64 | 65 | assert ( 66 | paths_nydus is not None 67 | and len(paths_nydus) == 2 68 | and len(paths_nydus[0]) + len(paths_nydus[1]) == 7 69 | ) 70 | 71 | height_map = np.where(walkable_grid == 0, 24, 8).astype(np.uint8) 72 | 73 | playable_area = Rect([1, 1, 38, 38]) 74 | map_info = CMapInfo(walkable_grid, height_map, playable_area, "CExtensionTest") 75 | assert len(map_info.overlord_spots) == 2 76 | assert len(map_info.chokes) == 5 77 | 78 | # testing that the main line actually exists, was a previous bug 79 | for choke in map_info.chokes: 80 | assert choke.main_line[0] != choke.main_line[1] 81 | -------------------------------------------------------------------------------- /tests/test_docs.py: -------------------------------------------------------------------------------- 1 | import doctest 2 | 3 | from map_analyzer.settings import ROOT_DIR 4 | from map_analyzer.utils import get_map_files_folder 5 | 6 | from .mocksetup import * 7 | 8 | goldenwall = os.path.join(get_map_files_folder(), "GoldenWallLE.xz") 9 | map_data = mock_map_data(goldenwall) 10 | 11 | 12 | """ 13 | uncomment and run the function below to test doc strings without running the entire test suite 14 | """ 15 | 16 | # def test_docstrings() -> None: 17 | # test_files = [] 18 | # for root, dirs, files in os.walk(ROOT_DIR): 19 | # for f in files: 20 | # if f.endswith('.py'): 21 | # test_files.append(os.path.join(root, f)) 22 | # for f in test_files: 23 | # print(f) 24 | # doctest.testfile(f"{f}", extraglobs={'self': map_data}, verbose=True) 25 | -------------------------------------------------------------------------------- /tests/test_pathing.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import numpy as np 4 | from _pytest.logging import LogCaptureFixture 5 | from _pytest.python import Metafunc 6 | from sc2.position import Point2 7 | 8 | from map_analyzer import Region 9 | from map_analyzer.destructibles import ( 10 | destructable_2x2, 11 | destructable_2x4, 12 | destructable_2x6, 13 | destructable_4x2, 14 | destructable_4x4, 15 | destructable_4x12, 16 | destructable_6x2, 17 | destructable_6x6, 18 | destructable_12x4, 19 | destructable_BLUR, 20 | destructable_ULBR, 21 | ) 22 | from map_analyzer.MapData import MapData 23 | from map_analyzer.utils import get_map_file_list, get_map_files_folder, mock_map_data 24 | from tests.mocksetup import get_map_datas, get_random_point, logger 25 | 26 | logger = logger 27 | 28 | 29 | # From 30 | # https://docs.pytest.org/en/latest/example/parametrize.html#a-quick-port-of-testscenarios 31 | def pytest_generate_tests(metafunc: Metafunc) -> None: 32 | # noinspection PyGlobalUndefined 33 | global argnames 34 | idlist = [] 35 | argvalues = [] 36 | if metafunc.cls is not None: 37 | for scenario in metafunc.cls.scenarios: 38 | idlist.append(scenario[0]) 39 | items = scenario[1].items() 40 | argnames = [x[0] for x in items] 41 | argvalues.append(([x[1] for x in items])) 42 | metafunc.parametrize(argnames, argvalues, ids=idlist, scope="class") 43 | 44 | 45 | def test_destructable_types() -> None: 46 | map_list = get_map_file_list() 47 | dest_types = set() 48 | for map in map_list: 49 | map_data = mock_map_data(map) 50 | for dest in map_data.bot.destructables: 51 | dest_types.add((dest.type_id, dest.name)) 52 | 53 | rock_types = set() 54 | rock_types.update(destructable_ULBR) 55 | rock_types.update(destructable_BLUR) 56 | rock_types.update(destructable_6x2) 57 | rock_types.update(destructable_4x4) 58 | rock_types.update(destructable_2x4) 59 | rock_types.update(destructable_2x2) 60 | rock_types.update(destructable_2x6) 61 | rock_types.update(destructable_4x2) 62 | rock_types.update(destructable_4x12) 63 | rock_types.update(destructable_6x6) 64 | rock_types.update(destructable_12x4) 65 | 66 | for dest in dest_types: 67 | handled = False 68 | type_id = dest[0] 69 | name = dest[1].lower() 70 | if "mineralfield450" in name: 71 | handled = True 72 | elif "unbuildable" in name: 73 | handled = True 74 | elif "acceleration" in name: 75 | handled = True 76 | elif "inhibitor" in name: 77 | handled = True 78 | elif "dog" in name: 79 | handled = True 80 | elif "cleaningbot" in name: 81 | handled = True 82 | elif type_id in rock_types: 83 | handled = True 84 | 85 | assert handled, f"Destructable {type_id} with name {name} is not handled" 86 | 87 | 88 | def test_climber_grid() -> None: 89 | """assert that we can path through climb cells with climber grid, 90 | but not with normal grid""" 91 | path = os.path.join(get_map_files_folder(), "GoldenWallLE.xz") 92 | 93 | map_data = mock_map_data(path) 94 | start = (150, 95) 95 | goal = (110, 40) 96 | grid = map_data.get_pyastar_grid() 97 | path = map_data.pathfind(start=start, goal=goal, grid=grid) 98 | assert path is None 99 | grid = map_data.get_climber_grid() 100 | path = map_data.pathfind(start=start, goal=goal, grid=grid) 101 | assert path is None 102 | 103 | 104 | def test_minerals_walls() -> None: 105 | # attempting to path through mineral walls in goldenwall should fail 106 | path = os.path.join(get_map_files_folder(), "GoldenWallLE.xz") 107 | # logger.info(path) 108 | map_data = mock_map_data(path) 109 | start = (110, 95) 110 | goal = (110, 40) 111 | grid = map_data.get_pyastar_grid() 112 | path = map_data.pathfind(start=start, goal=goal, grid=grid) 113 | assert path is None 114 | # also test climber grid for nonpathables 115 | grid = map_data.get_climber_grid() 116 | path = map_data.pathfind(start=start, goal=goal, grid=grid) 117 | assert path is None 118 | 119 | # remove the mineral wall that is blocking pathing from 120 | # the left player's base to the bottom side of the map 121 | map_data.bot.destructables = map_data.bot.destructables.filter( 122 | lambda x: x.distance_to((46, 41)) > 5 123 | ) 124 | grid = map_data.get_pyastar_grid() 125 | path = map_data.pathfind(start=start, goal=goal, grid=grid) 126 | assert path is not None 127 | 128 | # attempting to path through tight pathways near destructables should work 129 | path = os.path.join(get_map_files_folder(), "AbyssalReefLE.xz") 130 | map_data = mock_map_data(path) 131 | start = (130, 25) 132 | goal = (125, 47) 133 | grid = map_data.get_pyastar_grid() 134 | path = map_data.pathfind(start=start, goal=goal, grid=grid) 135 | assert path is not None 136 | 137 | 138 | class TestPathing: 139 | """ 140 | Test DocString 141 | """ 142 | 143 | scenarios = [ 144 | (f"Testing {md.bot.game_info.map_name}", {"map_data": md}) 145 | for md in get_map_datas() 146 | ] 147 | 148 | def test_region_connectivity(self, map_data: MapData) -> None: 149 | base = map_data.bot.townhalls[0] 150 | region = map_data.where_all(base.position_tuple)[0] 151 | destination = map_data.where_all( 152 | map_data.bot.enemy_start_locations[0].position 153 | )[0] 154 | all_possible_paths = map_data.region_connectivity_all_paths( 155 | start_region=region, goal_region=destination 156 | ) 157 | for p in all_possible_paths: 158 | assert destination in p, f"destination = {destination}" 159 | 160 | bad_request = map_data.region_connectivity_all_paths( 161 | start_region=region, goal_region=destination, not_through=[destination] 162 | ) 163 | assert bad_request == [] 164 | 165 | def test_handle_out_of_bounds_values(self, map_data: MapData) -> None: 166 | base = map_data.bot.townhalls[0] 167 | reg_start = map_data.where_all(base.position_tuple)[0] 168 | assert isinstance(reg_start, Region), ( 169 | f"reg_start = {reg_start}, " 170 | f"base = {base}, position_tuple = {base.position_tuple}" 171 | ) 172 | reg_end = map_data.where_all(map_data.bot.enemy_start_locations[0].position)[0] 173 | p0 = reg_start.center 174 | p1 = reg_end.center 175 | pts = [] 176 | r = 10 177 | for i in range(50): 178 | pts.append(get_random_point(-500, -250, -500, -250)) 179 | 180 | arr = map_data.get_pyastar_grid() 181 | for p in pts: 182 | arr = map_data.add_cost(p, r, arr) 183 | path = map_data.pathfind(p0, p1, grid=arr) 184 | assert path is not None, f"path = {path}" 185 | 186 | def test_handle_illegal_weights(self, map_data: MapData) -> None: 187 | base = map_data.bot.townhalls[0] 188 | reg_start = map_data.where_all(base.position_tuple)[0] 189 | assert isinstance(reg_start, Region), ( 190 | f"reg_start = {reg_start}, base = {base}," 191 | f" position_tuple = {base.position_tuple}" 192 | ) 193 | reg_end = map_data.where_all(map_data.bot.enemy_start_locations[0].position)[0] 194 | p0 = reg_start.center 195 | p1 = reg_end.center 196 | pts = [] 197 | r = 10 198 | for i in range(10): 199 | pts.append(get_random_point(20, 180, 20, 180)) 200 | 201 | arr = map_data.get_pyastar_grid() 202 | for p in pts: 203 | arr = map_data.add_cost(p, r, arr, weight=-100) 204 | path = map_data.pathfind(p0, p1, grid=arr) 205 | assert path is not None, f"path = {path}" 206 | 207 | def test_find_lowest_cost_points(self, map_data: MapData) -> None: 208 | cr = 7 209 | safe_query_radius = 14 210 | expected_max_distance = 2 * safe_query_radius 211 | 212 | influence_grid = map_data.get_air_vs_ground_grid() 213 | cost_point = (50, 130) 214 | influence_grid = map_data.add_cost( 215 | position=cost_point, radius=cr, grid=influence_grid 216 | ) 217 | safe_points = map_data.find_lowest_cost_points( 218 | from_pos=cost_point, radius=safe_query_radius, grid=influence_grid 219 | ) 220 | assert ( 221 | safe_points[0][0], 222 | np.integer, 223 | ), f"safe_points[0][0] = {safe_points[0][0]}, type {type(safe_points[0][0])}" 224 | assert isinstance( 225 | safe_points[0][1], np.integer 226 | ), f"safe_points[0][1] = {safe_points[0][1]}, type {type(safe_points[0][1])}" 227 | cost = influence_grid[safe_points[0]] 228 | for p in safe_points: 229 | assert influence_grid[p] == cost, ( 230 | f"grid type = air_vs_ground_grid, p = {p}, " 231 | f"influence_grid[p] = {influence_grid[p]}, expected cost = {cost}" 232 | ) 233 | assert map_data.distance(cost_point, p) < expected_max_distance 234 | 235 | influence_grid = map_data.get_clean_air_grid() 236 | cost_point = (50, 130) 237 | influence_grid = map_data.add_cost( 238 | position=cost_point, radius=cr, grid=influence_grid 239 | ) 240 | safe_points = map_data.find_lowest_cost_points( 241 | from_pos=cost_point, radius=safe_query_radius, grid=influence_grid 242 | ) 243 | cost = influence_grid[safe_points[0]] 244 | for p in safe_points: 245 | assert influence_grid[p] == cost, ( 246 | f"grid type = clean_air_grid, p = {p}, " 247 | f"influence_grid[p] = {influence_grid[p]}, expected cost = {cost}" 248 | ) 249 | assert map_data.distance(cost_point, p) < expected_max_distance 250 | 251 | influence_grid = map_data.get_pyastar_grid() 252 | cost_point = (50, 130) 253 | influence_grid = map_data.add_cost( 254 | position=cost_point, radius=cr, grid=influence_grid 255 | ) 256 | safe_points = map_data.find_lowest_cost_points( 257 | from_pos=cost_point, radius=safe_query_radius, grid=influence_grid 258 | ) 259 | cost = influence_grid[safe_points[0]] 260 | for p in safe_points: 261 | assert influence_grid[p] == cost, ( 262 | f"grid type = pyastar_grid, p = {p}, " 263 | f"influence_grid[p] = {influence_grid[p]}, expected cost = {cost}" 264 | ) 265 | assert map_data.distance(cost_point, p) < expected_max_distance 266 | 267 | influence_grid = map_data.get_climber_grid() 268 | cost_point = (50, 130) 269 | influence_grid = map_data.add_cost( 270 | position=cost_point, radius=cr, grid=influence_grid 271 | ) 272 | safe_points = map_data.find_lowest_cost_points( 273 | from_pos=cost_point, radius=safe_query_radius, grid=influence_grid 274 | ) 275 | cost = influence_grid[safe_points[0]] 276 | for p in safe_points: 277 | assert influence_grid[p] == cost, ( 278 | f"grid type = climber_grid, p = {p}, " 279 | f"influence_grid[p] = {influence_grid[p]}, expected cost = {cost}" 280 | ) 281 | assert map_data.distance(cost_point, p) < expected_max_distance 282 | 283 | def test_clean_air_grid_smoothing(self, map_data: MapData) -> None: 284 | default_weight = 2 285 | base = map_data.bot.townhalls[0] 286 | reg_start = map_data.where_all(base.position_tuple)[0] 287 | reg_end = map_data.where_all(map_data.bot.enemy_start_locations[0].position)[0] 288 | p0 = Point2(reg_start.center) 289 | p1 = Point2(reg_end.center) 290 | grid = map_data.get_clean_air_grid(default_weight=default_weight) 291 | cost_points = [(87, 76), (108, 64), (97, 53)] 292 | cost_points = list(map(Point2, cost_points)) 293 | for cost_point in cost_points: 294 | grid = map_data.add_cost(position=cost_point, radius=7, grid=grid) 295 | path = map_data.pathfind(start=p0, goal=p1, grid=grid, smoothing=True) 296 | assert len(path) < 50 297 | 298 | def test_clean_air_grid_no_smoothing(self, map_data: MapData) -> None: 299 | """ 300 | non diagonal path should be longer, but still below 250 301 | """ 302 | default_weight = 2 303 | base = map_data.bot.townhalls[0] 304 | reg_start = map_data.where_all(base.position_tuple)[0] 305 | reg_end = map_data.where_all(map_data.bot.enemy_start_locations[0].position)[0] 306 | p0 = Point2(reg_start.center) 307 | p1 = Point2(reg_end.center) 308 | grid = map_data.get_clean_air_grid(default_weight=default_weight) 309 | cost_points = [(87, 76), (108, 64), (97, 53)] 310 | cost_points = list(map(Point2, cost_points)) 311 | for cost_point in cost_points: 312 | grid = map_data.add_cost(position=cost_point, radius=7, grid=grid) 313 | path = map_data.pathfind(start=p0, goal=p1, grid=grid, smoothing=False) 314 | assert len(path) < 250 315 | 316 | def test_air_vs_ground(self, map_data: MapData) -> None: 317 | default_weight = 99 318 | grid = map_data.get_air_vs_ground_grid(default_weight=default_weight) 319 | ramps = map_data.map_ramps 320 | path_array = map_data.pather.default_grid 321 | for ramp in ramps: 322 | for point in ramp.points: 323 | if path_array[point.x][point.y] == 1: 324 | assert grid[point.x][point.y] == default_weight, f"point {point}" 325 | 326 | def test_sensitivity(self, map_data: MapData) -> None: 327 | base = map_data.bot.townhalls[0] 328 | reg_start = map_data.where_all(base.position_tuple)[0] 329 | reg_end = map_data.where_all(map_data.bot.enemy_start_locations[0].position)[0] 330 | p0 = reg_start.center 331 | p1 = reg_end.center 332 | arr = map_data.get_pyastar_grid() 333 | path_pure = map_data.pathfind(p0, p1, grid=arr) 334 | path_sensitive_5 = map_data.pathfind(p0, p1, grid=arr, sensitivity=5) 335 | path_sensitive_1 = map_data.pathfind(p0, p1, grid=arr, sensitivity=1) 336 | assert len(path_sensitive_5) < len(path_pure) 337 | assert (p in path_pure for p in path_sensitive_5) 338 | assert path_sensitive_1 == path_pure 339 | 340 | def test_pathing_influence( 341 | self, map_data: MapData, caplog: LogCaptureFixture 342 | ) -> None: 343 | logger.info(map_data) 344 | base = map_data.bot.townhalls[0] 345 | reg_start = map_data.where_all(base.position_tuple)[0] 346 | reg_end = map_data.where_all(map_data.bot.enemy_start_locations[0].position)[0] 347 | p0 = reg_start.center 348 | p1 = reg_end.center 349 | pts = [] 350 | r = 10 351 | for i in range(50): 352 | pts.append(get_random_point(0, 200, 0, 200)) 353 | 354 | arr = map_data.get_pyastar_grid() 355 | for p in pts: 356 | arr = map_data.add_cost(p, r, arr) 357 | path = map_data.pathfind(p0, p1, grid=arr) 358 | assert path is not None 359 | -------------------------------------------------------------------------------- /tests/test_suite.py: -------------------------------------------------------------------------------- 1 | from random import randint 2 | 3 | from _pytest.python import Metafunc 4 | from hypothesis import given, settings 5 | from hypothesis import strategies as st 6 | from loguru import logger 7 | from sc2.position import Point2 8 | 9 | from map_analyzer.MapData import MapData 10 | from map_analyzer.utils import get_map_file_list, mock_map_data 11 | from tests.mocksetup import get_map_datas, random 12 | 13 | 14 | # From 15 | # https://docs.pytest.org/en/latest/example/parametrize.html#a-quick-port-of-testscenarios 16 | def pytest_generate_tests(metafunc: Metafunc) -> None: 17 | # noinspection PyGlobalUndefined 18 | global argnames 19 | idlist = [] 20 | argvalues = [] 21 | if metafunc.cls is not None: 22 | for scenario in metafunc.cls.scenarios: 23 | idlist.append(scenario[0]) 24 | items = scenario[1].items() 25 | argnames = [x[0] for x in items] 26 | argvalues.append(([x[1] for x in items])) 27 | metafunc.parametrize(argnames, argvalues, ids=idlist, scope="class") 28 | 29 | 30 | @given(st.integers(min_value=1, max_value=100), st.integers(min_value=1, max_value=100)) 31 | @settings(max_examples=5, deadline=None, verbosity=3, print_blob=True) 32 | def test_mapdata(n, m): 33 | map_files = get_map_file_list() 34 | map_data = mock_map_data(random.choice(map_files)) 35 | # map_data.plot_map() 36 | logger.info(f"Loaded Map : {map_data.bot.game_info.map_name}, n,m = {n}, {m}") 37 | # tuples 38 | points = [(i, j) for i in range(n + 1) for j in range(m + 1)] 39 | set_points = set(points) 40 | indices = map_data.points_to_indices(set_points) 41 | i = randint(0, n) 42 | j = randint(0, m) 43 | assert (i, j) in points 44 | assert (i, j) in set_points 45 | assert i in indices[0] and j in indices[1] 46 | new_points = map_data.indices_to_points(indices) 47 | assert new_points == set_points 48 | 49 | # Point2's 50 | points = [Point2((i, j)) for i in range(n + 1) for j in range(m + 1)] 51 | 52 | for point in points: 53 | assert point is not None 54 | set_points = set(points) 55 | indices = map_data.points_to_indices(set_points) 56 | i = randint(0, n) 57 | j = randint(0, m) 58 | assert (i, j) in points 59 | assert (i, j) in set_points 60 | assert i in indices[0] and j in indices[1] 61 | new_points = map_data.indices_to_points(indices) 62 | assert new_points == set_points 63 | 64 | 65 | class TestSanity: 66 | """ 67 | Test DocString 68 | """ 69 | 70 | scenarios = [ 71 | (f"Testing {md.bot.game_info.map_name}", {"map_data": md}) 72 | for md in get_map_datas() 73 | ] 74 | 75 | def test_polygon(self, map_data: MapData) -> None: 76 | for polygon in map_data.polygons: 77 | polygon.plot(testing=True) 78 | assert polygon not in polygon.areas 79 | assert polygon.width > 0 80 | assert polygon.area > 0 81 | assert polygon.is_inside_point(polygon.center) 82 | 83 | extended_pts = polygon.points.union(polygon.outer_perimeter_points) 84 | assert polygon.points == extended_pts 85 | 86 | for point in extended_pts: 87 | assert polygon.is_inside_point(point) is True 88 | 89 | # https://github.com/BurnySc2/python-sc2/issues/62 90 | assert isinstance(point, Point2) 91 | assert type(point[0] == int) 92 | 93 | for point in polygon.corner_points: 94 | assert point in polygon.corner_array 95 | 96 | def test_regions(self, map_data: MapData) -> None: 97 | for region in map_data.regions.values(): 98 | for p in region.points: 99 | assert region in map_data.where_all( 100 | p 101 | ), f"expected {region}, got {map_data.where_all(p)}, point {p}" 102 | assert region == map_data.where(region.center) 103 | 104 | # coverage 105 | region.plot(testing=True) 106 | 107 | def test_ramps(self, map_data: MapData) -> None: 108 | for ramp in map_data.map_ramps: 109 | # on some maps the ramp may be hit a region edge 110 | # and ends up connecting to 3 regions 111 | # could think about whether this is desirable 112 | # or if we should select the 2 main regions 113 | # the ramp is connecting 114 | assert len(ramp.regions) == 2 or len(ramp.regions) == 3, f"ramp = {ramp}" 115 | 116 | def test_chokes(self, map_data: MapData) -> None: 117 | for choke in map_data.map_chokes: 118 | for p in choke.points: 119 | assert choke in map_data.where_all(p), logger.error( 120 | f"" 122 | ) 123 | 124 | def test_vision_blockers(self, map_data: MapData) -> None: 125 | all_chokes = map_data.map_chokes 126 | for vb in map_data.map_vision_blockers: 127 | assert vb in all_chokes 128 | for p in vb.points: 129 | assert vb in map_data.where_all(p), logger.error( 130 | f"" 133 | ) 134 | -------------------------------------------------------------------------------- /vb.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | import subprocess 4 | from pathlib import Path 5 | 6 | __author__ = "Elad Yaniv" 7 | 8 | import click 9 | 10 | VERSION_REGEX = r"version=\"(\d*[.]\d*[.]\d*)" 11 | 12 | 13 | @click.group(help="Version Bump CLI") 14 | def vb(): 15 | pass 16 | 17 | 18 | def update_readme_to_sphinx(): 19 | import re 20 | 21 | regex = r"([#]\s)([A-Z]\w+)(\s?\n)" 22 | subst = "\\2\\n---------------\\n" 23 | with open("README.md", "r") as f: 24 | r_parsed = f.read() 25 | title = "# QuickWalkThrough\n============" 26 | r_parsed = r_parsed.replace("# SC2MapAnalysis", title) 27 | r_result = re.sub(regex, subst, r_parsed, 0, re.MULTILINE) 28 | with open("README.md", "w") as f: 29 | f.write(r_result) 30 | 31 | 32 | def parse_setup(): 33 | with open("setup.py", "r") as f: 34 | setup_parsed = f.read() 35 | return setup_parsed 36 | 37 | 38 | @vb.command(help="sphinx make for gh pages") 39 | def makedocs(): 40 | p = Path() 41 | path = p.joinpath("docs").absolute() 42 | click.echo(click.style(f"calling {path}//make github", fg="green")) 43 | subprocess.check_call(f"{path}//make github", shell=True) 44 | 45 | 46 | @vb.command(help="print setup.py") 47 | def printsetup(): 48 | setup_parsed = parse_setup() 49 | click.echo(click.style(setup_parsed, fg="blue")) 50 | 51 | 52 | @vb.command(help="MonkeyType apply on list-modules") 53 | @click.option("--apply/--no-apply", default=False) 54 | def mt(apply): 55 | click.echo(click.style("This could take a few seconds", fg="blue")) 56 | encoded_modules = subprocess.check_output("monkeytype list-modules", shell=True) 57 | list_modules = encoded_modules.decode().split("\r\n") 58 | to_exclude = {"mocksetup"} 59 | if apply: 60 | for m in list_modules: 61 | if [x for x in to_exclude if x in m] == []: 62 | click.echo(click.style(f"Applying on {m}", fg="green")) 63 | subprocess.check_call(f"monkeytype apply {m}", shell=True) 64 | 65 | 66 | @vb.command(help="Get current version") 67 | def gv(): 68 | click.echo("Running git describe") 69 | subprocess.check_call("git describe") 70 | 71 | 72 | @vb.command(help="Bump Minor") 73 | def bumpminor(): 74 | setup_parsed = parse_setup() 75 | old_version_regex = VERSION_REGEX 76 | old_version = re.findall(old_version_regex, setup_parsed)[0] 77 | minor = re.findall(r"([.]\d*)", old_version)[-1] 78 | minor = minor.replace(".", "") 79 | click.echo("Current Version: " + click.style(old_version, fg="green")) 80 | click.echo("Minor Found: " + click.style(minor, fg="green")) 81 | bump = str(int(minor) + 1) 82 | click.echo("Bumping to : " + click.style(bump, fg="blue")) 83 | new_version = str(old_version).replace(minor, bump) 84 | click.echo("Updated Version: " + click.style(new_version, fg="red")) 85 | b_minor(new_version) 86 | 87 | 88 | def b_minor(new_version): 89 | setup_parsed = parse_setup() 90 | old_version_regex = VERSION_REGEX 91 | old_version = re.findall(old_version_regex, setup_parsed)[0] 92 | setup_updated = setup_parsed.replace(old_version, new_version) 93 | with open("setup.py", "w") as f: 94 | f.write(setup_updated) 95 | 96 | curdir = os.getcwd() 97 | click.echo(click.style(curdir + "\\standard-version", fg="blue")) 98 | subprocess.check_call("git fetch", shell=True) 99 | subprocess.check_call("git pull", shell=True) 100 | subprocess.check_call("git add setup.py", shell=True) 101 | subprocess.check_call(f'git commit -m " setup bump {new_version} " ', shell=True) 102 | subprocess.check_call(f"standard-version --release-as {new_version}", shell=True) 103 | # subprocess.check_call('git push --follow-tags origin', shell=True) 104 | 105 | 106 | @vb.command(help="Custom git log command for last N days") 107 | @click.argument("days") 108 | def gh(days): 109 | click.echo( 110 | click.style("Showing last ", fg="blue") 111 | + click.style(days, fg="green") 112 | + click.style(" days summary", fg="blue") 113 | ) 114 | subprocess.check_call("git fetch", shell=True) 115 | subprocess.check_call( 116 | f"git log --oneline --decorate --graph --all -{days}", shell=True 117 | ) 118 | 119 | 120 | if __name__ == "__main__": 121 | vb(prog_name="python -m vb") 122 | --------------------------------------------------------------------------------