├── .env.template ├── .gitignore ├── .npmignore ├── .prettierrc ├── README-CN.md ├── README.md ├── TODO.md ├── package-lock.json ├── package.json ├── src ├── bot │ ├── bot.ts │ └── logic │ │ ├── awareness.ts │ │ ├── building │ │ ├── antiAirStaticDefence.ts │ │ ├── antiGroundStaticDefence.ts │ │ ├── artilleryUnit.ts │ │ ├── basicAirUnit.ts │ │ ├── basicBuilding.ts │ │ ├── basicGroundUnit.ts │ │ ├── buildingRules.ts │ │ ├── common.ts │ │ ├── harvester.ts │ │ ├── powerPlant.ts │ │ ├── queueController.ts │ │ └── resourceCollectionBuilding.ts │ │ ├── common │ │ ├── rulesCache.ts │ │ ├── scout.ts │ │ └── utils.ts │ │ ├── composition │ │ ├── alliedCompositions.ts │ │ ├── common.ts │ │ └── sovietCompositions.ts │ │ ├── map │ │ ├── map.ts │ │ └── sector.ts │ │ ├── mission │ │ ├── actionBatcher.ts │ │ ├── mission.ts │ │ ├── missionController.ts │ │ ├── missionFactories.ts │ │ └── missions │ │ │ ├── attackMission.ts │ │ │ ├── defenceMission.ts │ │ │ ├── engineerMission.ts │ │ │ ├── expansionMission.ts │ │ │ ├── retreatMission.ts │ │ │ ├── scoutingMission.ts │ │ │ ├── squads │ │ │ ├── combatSquad.ts │ │ │ ├── common.ts │ │ │ └── squad.ts │ │ │ └── triggers │ │ │ ├── aiTaskForces.ts │ │ │ ├── aiTeamTypes.ts │ │ │ ├── aiTriggerTypes.ts │ │ │ ├── scriptTypes.ts │ │ │ └── triggerManager.ts │ │ └── threat │ │ ├── threat.ts │ │ └── threatCalculator.ts ├── dummyBot │ └── dummyBot.ts └── main.ts └── tsconfig.json /.env.template: -------------------------------------------------------------------------------- 1 | SERVER_URL="wss://gserv-sea1.chronodivide.com" 2 | CLIENT_URL="https://game.chronodivide.com/" 3 | ONLINE_BOT_NAME="username_of_your_bot" 4 | ONLINE_BOT_PASSWORD="password_of_your_bot" 5 | PLAYER_NAME="username_of_human_account" -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .vs/ 2 | .vscode/ 3 | .idea/ 4 | dist/ 5 | node_modules/ 6 | replays/ 7 | *.tgz 8 | *.rpl 9 | *.log 10 | *.cpuprofile 11 | .env 12 | *.ini 13 | *.map 14 | *.mpr -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .vs/ 2 | .vscode/ 3 | .idea/ 4 | dist/ 5 | node_modules/ 6 | replays/ 7 | *.tgz 8 | *.rpl 9 | *.log 10 | *.cpuprofile 11 | .env 12 | *.ini 13 | .prettierrc 14 | *.md 15 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 4, 3 | "useTabs": false, 4 | "printWidth": 120 5 | } 6 | -------------------------------------------------------------------------------- /README-CN.md: -------------------------------------------------------------------------------- 1 | # Supalosa关于网页版红警AI的实现 2 | 3 | [English Version Doc](README.md) 4 | 5 | [Chrono Divide](https://chronodivide.com/) 是一个在浏览器中重新构建的红色警戒2游戏。它目前已经具备完整的功能,并允许与其他玩家进行在线对战、游玩单机模式和导入MOD。 6 | 7 | 它还提供了[一个构建机器人的 API](https://discord.com/channels/771701199812558848/842700851520339988),目前本仓库所开发的AI已经集成到Chronodivide正式游戏中,现在在持续完善。 8 | 9 | ## 开发状态和未来的计划 10 | 11 | Chrono Divide 的开发者表示有兴趣将这个机器人直接整合到游戏中。因此,我打算实现缺失的功能,为人类玩家创建一个令人满意的 AI 对手。 12 | 13 | 从方向上来说,这意味着我不打算让这个 AI 成为一个拥有完美阵容或微操作的对手,而是希望它能成为**新手玩家**的有趣挑战。 14 | 15 | 请查看 TODO.md,其中列出了计划为机器人进行的结构性更改和功能改进的细分清单。 16 | 17 | 欢迎您贡献代码到代码库,甚至可以 fork 代码库并构建您自己的版本。 18 | 19 | ## 安装说明 20 | 21 | 请使用Node.js 14版本,更高的Node版本目前不被支持。推荐使用nvm管理Node版本,方便切换。 22 | 23 | 建议使用官方原版红色警戒2安装目录。**如果你更改了游戏ini,那么可能无法运行,请知悉!** 24 | 25 | ```sh 26 | npm install 27 | npm run build 28 | npx cross-env MIX_DIR="C:\指向你安装的红色警戒2目录" npm start 29 | ``` 30 | 31 | 这将创建一个回放(`.rpl`)文件,可以[导入到实际游戏中](https://game.chronodivide.com/)。 32 | 33 | 你可以编辑 `exampleBot.ts` 来定义对局。你可以看到 `const mapName = "..."` 这样的代码,去更改他以改变地图; 或者 `const offlineSettings1v1` 这样的代码,去更改他以改变bot国家。 34 | 35 | ## 真人与机器人对战 36 | 37 | 在Chronodivide的单机模式内,你可以和之前发布的Supalosa Bot对战。但是当前仓库的最新版本**只能供开发者游玩**,也就是正在看仓库的你。跟随下面的步骤,开启在线对战游玩方法吧。 38 | 39 | ### 初始设置步骤(仅需一次) 40 | 41 | 1. 使用官方客户端在 [https://game.chronodivide.com](https://game.chronodivide.com) 为您的机器人创建一个 Chronodivide 帐户。 42 | 2. 如果您还没有帐户,请使用相同的链接为自己创建一个 Chronodivide 帐户。 43 | 3. 将 `.env.template` 复制为 `.env`。`.env` 文件不会被提交到代码库中。 44 | 4. 将 `ONLINE_BOT_NAME` 的值设置为步骤 1 中机器人的用户名。 45 | 5. 将 `ONLINE_BOT_PASSWORD` 的值设置为步骤 1 中的密码。 46 | 6. 将 `PLAYER_NAME` 的值设置为人类帐户的用户名。 47 | 7. (可选)如果您想连接到另一个服务器,请更改 `SERVER_URL`。步骤 1 和步骤 2 中的 Chronodivide 帐户需要存在于该服务器上。 48 | 49 | ### 运行机器人并连接到游戏 50 | 51 | 使用 `ONLINE_MATCH=1` 启动机器人。例如: 52 | 53 | ```sh 54 | ONLINE_MATCH=1 npx cross-env MIX_DIR="${GAMEPATH}" npm --node-options="${NODE_OPTIONS} --inspect" start 55 | ``` 56 | 57 | 机器人将连接到服务器,并应返回如下输出: 58 | 59 | ```sh 60 | You may use the following link(s) to join, after the game is created: 61 | 62 | https://game.chronodivide.com/#/game/12345/supalosa 63 | 64 | 65 | Press ENTER to create the game now... 66 | ``` 67 | 68 | 进入控制台输出的这个地址,在上面的例子中,这个是“https://game.chronodivide.com/#/game/12345/supalosa”,请你以控制台实际输出为准。进入地址后,根据提示,**首先使用真人账号登录**,然后在控制台终端中按 ENTER 键,以便机器人可以创建游戏。 69 | 70 | 重要提示:不要过早按下 ENTER 键,因为人类连接到比赛的时间非常短暂。 71 | 72 | ## 调试 73 | 74 | 要生成启用调试的回放: 75 | 76 | ```sh 77 | npx cross-env MIX_DIR="C:\path_to_ra2_install_dir" npm --node-options="${NODE_OPTIONS} --inspect" start 78 | ``` 79 | 80 | 要记录机器人生成的所有操作: 81 | 82 | ```sh 83 | DEBUG_LOGGING="action" npx cross-env MIX_DIR="${GAMEPATH}" npm --node-options="${NODE_OPTIONS} --inspect" start 84 | ``` 85 | 86 | 我们还利用了 CD 提供的游戏内机器人调试功能。这些基本上是仅限机器人的操作,保存在回放中,但在观看回放之前,您必须在 CD 客户端中启用可视化功能,方法是在开发控制台中输入以下内容: 87 | 88 | ``` 89 | r.debug_text = true; 90 | ``` 91 | 92 | 这将对已配置为 `setDebugMode(true)` 的机器人进行调试,这是在 `exampleBot.ts` 中完成的。 93 | 94 | ## 发布 95 | 96 | 在 `~/.npmrc` 或适当的位置设置 npmjs 令牌。 97 | 98 | ``` 99 | npm publish 100 | ``` 101 | 102 | ## 贡献者 103 | 104 | - use-strict: Chrono Divide创始人 105 | - Libi: 改进建筑摆放性能 106 | - Dogemoon(ra2web-bot): 提供中文文档,修复一个因文件名驼峰导致的调试问题 107 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Supalosa's Chrono Divide Bot 2 | 3 | [中文版文档](README-CN.md) 4 | 5 | [Chrono Divide](https://chronodivide.com/) is a ground-up rebuild of Red Alert 2 in the browser. It is feature-complete and allows for online skirmish play against other players. 6 | It also provides [an API to build bots](https://discord.com/channels/771701199812558848/842700851520339988), as there is no built-in AI yet. 7 | 8 | This repository is one such implementation of a bot. The original template for the bot is available at [game-api-playground](https://github.com/chronodivide/game-api-playground/blob/master/README.md). 9 | 10 | ## Development State and Future plans 11 | 12 | The developer of Chrono Divide has expressed interest in integrating this bot into the game directly. As a consequence, I am aiming to implement missing features to create a satisfactory AI opponent for humans. 13 | Directionally, this means I am not looking to make this AI a perfect opponent with perfect compositions or micro, and instead hope that it can be a fun challenge for newer players. 14 | 15 | See `TODO.md` for a granular list of structural changes and feature improvements that are planned for the bot. 16 | 17 | Feel free to contribute to the repository, or even fork the repo and build your own version. 18 | 19 | ## Install instructions 20 | 21 | Node 14 is required by the Chrono Divide API. Higher versions are not supported yet. 22 | 23 | ```sh 24 | npm install 25 | npm run build 26 | npx cross-env MIX_DIR="C:\path_to_ra2_install_dir" npm start 27 | ``` 28 | 29 | This will create a replay (`.rpl`) file that can be [imported into the live game](https://game.chronodivide.com/). 30 | 31 | You can modify `exampleBot.ts` to configure the match. You will most likely want to look at the line with `const mapName = "..."` to change the map, or the `const offlineSettings1v1` to change the bot countries. 32 | 33 | ## Playing against the bot 34 | 35 | Currently, playing against this bot **is only possible for developers**, because it requires you to run this repository from source. Follow these steps to set up online play. 36 | 37 | ### Initial set up steps (one time only) 38 | 39 | 1. Create a Chronodivide account for your bot using the official client at [https://game.chronodivide.com]. 40 | 2. If you don't already have one, create a Chronodivide account for yourself using the same link, 41 | 3. Copy `.env.template` to `.env`. The `.env` file is not checked into the repo. 42 | 4. Set the value of `ONLINE_BOT_NAME` to the username of the bot from step 1. 43 | 5. Set the value of `ONLINE_BOT_PASSWORD` to the password from step 1. 44 | 6. Set the value of `PLAYER_NAME` to the human's account name. 45 | 7. (Optional) Change `SERVER_URL` if you want to connect to another server. The Chronodivide accounts from step 1 and 2 need to be present on that server. 46 | 47 | ### Running the bot and connecting to the game 48 | 49 | Start the bot with `ONLINE_MATCH=1`. For example: 50 | 51 | ```sh 52 | ONLINE_MATCH=1 npx cross-env MIX_DIR="${GAMEPATH}" npm --node-options="${NODE_OPTIONS} --inspect" start 53 | ``` 54 | 55 | The bot will connect to the server and should return output like this: 56 | 57 | ``` 58 | You may use the following link(s) to join, after the game is created: 59 | 60 | https://game.chronodivide.com/#/game/12345/supalosa 61 | 62 | 63 | Press ENTER to create the game now... 64 | ``` 65 | 66 | Navigate to the link, **log in using the human credentials first**, then hit ENTER in the terminal so the bot can create the game. 67 | Do not hit ENTER too early, as there is a very narrow window for the human connect to the match. 68 | 69 | ## Debugging 70 | 71 | To generate a replay with debugging enabled: 72 | 73 | ```sh 74 | npx cross-env MIX_DIR="C:\path_to_ra2_install_dir" npm --node-options="${NODE_OPTIONS} --inspect" start 75 | ``` 76 | 77 | To log all actions generated by the bots: 78 | 79 | ```sh 80 | DEBUG_LOGGING="action" npx cross-env MIX_DIR="${GAMEPATH}" npm --node-options="${NODE_OPTIONS} --inspect" start 81 | ``` 82 | 83 | We also take advantage of the in-game bot debug functionality provided by CD. These are basically bot-only actions that are saved in the replay, but you must enable the visualisations in the CD client before watching the replay, by typing the following into the dev console:. 84 | 85 | ``` 86 | r.debug_text = true; 87 | ``` 88 | 89 | This will debug the bot which has been configured with `setDebugMode(true)`, this is done in `exampleBot.ts`. 90 | 91 | ## Publishing 92 | 93 | Have the npmjs token in ~/.npmrc or somewhere appropriate. 94 | 95 | ``` 96 | npm publish 97 | ``` 98 | 99 | ## Contributors 100 | 101 | - use-strict: Making Chrono Divide 102 | - Libi: Improvements to base structure placement performance 103 | - Dogemoon: Provide CN documentation and fix a debugging issue caused by camel case file names. 104 | -------------------------------------------------------------------------------- /TODO.md: -------------------------------------------------------------------------------- 1 | # Project roadmap 2 | 3 | ## Urgent 4 | 5 | ## Medium priority 6 | 7 | - Performance: Leader pathfinding 8 | - For a given squad of units, choose a leader (typically the slowest unit, tie-break with the lowest ID) and have other units in the squad follow that unit. 9 | - This should improve clustering of units, and hopefully we can remove the `centerOfMass` hack to keep groups of units together. 10 | - Feature: Detect Naval map 11 | - Currently the AI doesn't know if it's on a naval map or not, and will just sit in base forever. 12 | - Feature: Naval construction 13 | - The AI cannot produce GAYARD/NAYARD because it doesn't know how to place naval structures efficiently. 14 | - Feature: Naval/amphibious play 15 | - If a naval map is detected, we should try to bias towards naval units and various naval strategies (amphibious transports etc) 16 | - Feature: Superweapon usage 17 | - Self-explanatory 18 | - Performance/Feature: Debounce `BatchableActions` in `actionBatcher` 19 | - We have an `actionBatcher` to group up actions taken by units in a given tick, and submit them all at once. For example, if 5 units are being told to attack the same unit, it is submitted as one action with 5 IDs. 20 | - This improves performance and reduces the replay size. 21 | - There is further opportunity to improve this by remembering actions assigned _across_ ticks and do not submit them if the same action was submitted most recently. 22 | - This might simplify some mission logic (we can just spam unit `BatchableActions` safely) and also significantly reduce replay size. 23 | - There is a light version of this in `combatSquad`, where it remembers the last order given for a unit and doesn't submit the same order twice in a row. 24 | 25 | ## Low priority 26 | 27 | - Feature: `ai.ini` integration 28 | - It would be nice to use the attack groups and logic defined in `ai.ini`, so the AI tries strategies such as engineer rush, terrorist rush etc. 29 | - This might make the AI mod-friendly as well. 30 | -------------------------------------------------------------------------------- /package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@supalosa/chronodivide-bot", 3 | "version": "0.6.0", 4 | "lockfileVersion": 1, 5 | "requires": true, 6 | "dependencies": { 7 | "@chronodivide/game-api": { 8 | "version": "0.51.2", 9 | "resolved": "https://registry.npmjs.org/@chronodivide/game-api/-/game-api-0.51.2.tgz", 10 | "integrity": "sha512-amMWAwFKuUQ7vEq2BHmWTFiL02dtEF7TEPcgKPl+DLMJsfcRM7+I8pkA4wvuMgyU/dWFz7xOmoyJanML6UtH0g==", 11 | "dev": true, 12 | "requires": { 13 | "@types/three": "^0.93.31", 14 | "es-dirname": "^0.1.0", 15 | "file-system-access": "^1.0.1", 16 | "three": "^0.94.0", 17 | "websocket-polyfill": "0.0.3" 18 | } 19 | }, 20 | "@datastructures-js/heap": { 21 | "version": "4.3.2", 22 | "resolved": "https://registry.npmjs.org/@datastructures-js/heap/-/heap-4.3.2.tgz", 23 | "integrity": "sha512-7/9QSsIZ+wMG3C9++mz9iOjdtTp9C036PISHvNjG3eyFO8nXOBJQFKgeV7M6/+EPl+oXXFRGBb8Ue60LsqTqGw==" 24 | }, 25 | "@datastructures-js/priority-queue": { 26 | "version": "6.3.0", 27 | "resolved": "https://registry.npmjs.org/@datastructures-js/priority-queue/-/priority-queue-6.3.0.tgz", 28 | "integrity": "sha512-bJkryPys8zVYMCSjyBPYzJlw5V2kMeOYzGHRBXiGkwqTv8vZ/Ux5RO736T8Y6l3cH+ocbQV9UxlsFwA9qI4VhA==", 29 | "requires": { 30 | "@datastructures-js/heap": "^4.3.1" 31 | } 32 | }, 33 | "@timohausmann/quadtree-ts": { 34 | "version": "2.2.2", 35 | "resolved": "https://registry.npmjs.org/@timohausmann/quadtree-ts/-/quadtree-ts-2.2.2.tgz", 36 | "integrity": "sha512-emYbmmbTb+S3F75yvt03KQ3pMO3v/4BzjbAVnflbYq3zJfcurTfZHeriK8o7T0lFzBz3kBw2Pn41vlK3pD8spw==" 37 | }, 38 | "@types/node": { 39 | "version": "14.18.63", 40 | "resolved": "https://registry.npmjs.org/@types/node/-/node-14.18.63.tgz", 41 | "integrity": "sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==", 42 | "dev": true 43 | }, 44 | "@types/three": { 45 | "version": "0.93.31", 46 | "resolved": "https://registry.npmjs.org/@types/three/-/three-0.93.31.tgz", 47 | "integrity": "sha512-lR9eLY3gnrUDAdW4ujITgVTPEv1Oy2yoL3LlfKAnjQuzyjGSR+PvQuXuWmaDCD1IyWhkqbnol1nJwaW0MGe35A==", 48 | "dev": true 49 | }, 50 | "@types/wicg-file-system-access": { 51 | "version": "2020.9.8", 52 | "resolved": "https://registry.npmjs.org/@types/wicg-file-system-access/-/wicg-file-system-access-2020.9.8.tgz", 53 | "integrity": "sha512-ggMz8nOygG7d/stpH40WVaNvBwuyYLnrg5Mbyf6bmsj/8+gb6Ei4ZZ9/4PNpcPNTT8th9Q8sM8wYmWGjMWLX/A==", 54 | "dev": true 55 | }, 56 | "bufferutil": { 57 | "version": "4.0.8", 58 | "resolved": "https://registry.npmjs.org/bufferutil/-/bufferutil-4.0.8.tgz", 59 | "integrity": "sha512-4T53u4PdgsXqKaIctwF8ifXlRTTmEPJ8iEPWFdGZvcf7sbwYo6FKFEX9eNNAnzFZ7EzJAQ3CJeOtCRA4rDp7Pw==", 60 | "dev": true, 61 | "requires": { 62 | "node-gyp-build": "^4.3.0" 63 | } 64 | }, 65 | "d": { 66 | "version": "1.0.1", 67 | "resolved": "https://registry.npmjs.org/d/-/d-1.0.1.tgz", 68 | "integrity": "sha512-m62ShEObQ39CfralilEQRjH6oAMtNCV1xJyEx5LpRYUVN+EviphDgUc/F3hnYbADmkiNs67Y+3ylmlG7Lnu+FA==", 69 | "dev": true, 70 | "requires": { 71 | "es5-ext": "^0.10.50", 72 | "type": "^1.0.1" 73 | } 74 | }, 75 | "debug": { 76 | "version": "2.6.9", 77 | "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", 78 | "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", 79 | "dev": true, 80 | "requires": { 81 | "ms": "2.0.0" 82 | } 83 | }, 84 | "dotenv": { 85 | "version": "16.3.1", 86 | "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.3.1.tgz", 87 | "integrity": "sha512-IPzF4w4/Rd94bA9imS68tZBaYyBWSCE47V1RGuMrB94iyTOIEwRmVL2x/4An+6mETpLrKJ5hQkB8W4kFAadeIQ==" 88 | }, 89 | "es-dirname": { 90 | "version": "0.1.0", 91 | "resolved": "https://registry.npmjs.org/es-dirname/-/es-dirname-0.1.0.tgz", 92 | "integrity": "sha512-tcTj4pVFXe5EdiHCybjynDTlvkwuNN6JCg9+5BVB+n9qQoMyWOtxpREoL7rGO3sCIAX55Y6CYZi4s1c30uxvPg==", 93 | "dev": true 94 | }, 95 | "es5-ext": { 96 | "version": "0.10.62", 97 | "resolved": "https://registry.npmjs.org/es5-ext/-/es5-ext-0.10.62.tgz", 98 | "integrity": "sha512-BHLqn0klhEpnOKSrzn/Xsz2UIW8j+cGmo9JLzr8BiUapV8hPL9+FliFqjwr9ngW7jWdnxv6eO+/LqyhJVqgrjA==", 99 | "dev": true, 100 | "requires": { 101 | "es6-iterator": "^2.0.3", 102 | "es6-symbol": "^3.1.3", 103 | "next-tick": "^1.1.0" 104 | } 105 | }, 106 | "es6-iterator": { 107 | "version": "2.0.3", 108 | "resolved": "https://registry.npmjs.org/es6-iterator/-/es6-iterator-2.0.3.tgz", 109 | "integrity": "sha512-zw4SRzoUkd+cl+ZoE15A9o1oQd920Bb0iOJMQkQhl3jNc03YqVjAhG7scf9C5KWRU/R13Orf588uCC6525o02g==", 110 | "dev": true, 111 | "requires": { 112 | "d": "1", 113 | "es5-ext": "^0.10.35", 114 | "es6-symbol": "^3.1.1" 115 | } 116 | }, 117 | "es6-symbol": { 118 | "version": "3.1.3", 119 | "resolved": "https://registry.npmjs.org/es6-symbol/-/es6-symbol-3.1.3.tgz", 120 | "integrity": "sha512-NJ6Yn3FuDinBaBRWl/q5X/s4koRHBrgKAu+yGI6JCBeiu3qrcbJhwT2GeR/EXVfylRk8dpQVJoLEFhK+Mu31NA==", 121 | "dev": true, 122 | "requires": { 123 | "d": "^1.0.1", 124 | "ext": "^1.1.2" 125 | } 126 | }, 127 | "ext": { 128 | "version": "1.7.0", 129 | "resolved": "https://registry.npmjs.org/ext/-/ext-1.7.0.tgz", 130 | "integrity": "sha512-6hxeJYaL110a9b5TEJSj0gojyHQAmA2ch5Os+ySCiA1QGdS697XWY1pzsrSjqA9LDEEgdB/KypIlR59RcLuHYw==", 131 | "dev": true, 132 | "requires": { 133 | "type": "^2.7.2" 134 | }, 135 | "dependencies": { 136 | "type": { 137 | "version": "2.7.2", 138 | "resolved": "https://registry.npmjs.org/type/-/type-2.7.2.tgz", 139 | "integrity": "sha512-dzlvlNlt6AXU7EBSfpAscydQ7gXB+pPGsPnfJnZpiNJBDj7IaJzQlBZYGdEi4R9HmPdBv2XmWJ6YUtoTa7lmCw==", 140 | "dev": true 141 | } 142 | } 143 | }, 144 | "fetch-blob": { 145 | "version": "3.2.0", 146 | "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", 147 | "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", 148 | "dev": true, 149 | "requires": { 150 | "node-domexception": "^1.0.0", 151 | "web-streams-polyfill": "^3.0.3" 152 | } 153 | }, 154 | "file-system-access": { 155 | "version": "1.0.4", 156 | "resolved": "https://registry.npmjs.org/file-system-access/-/file-system-access-1.0.4.tgz", 157 | "integrity": "sha512-JDlhH+gJfZu/oExmtN4/6VX+q1etlrbJbR5uzoBa4BzfTRQbEXGFuGIBRk3ZcPocko3WdEclZSu+d/SByjG6Rg==", 158 | "dev": true, 159 | "requires": { 160 | "@types/wicg-file-system-access": "^2020.9.2", 161 | "fetch-blob": "^3.0.0", 162 | "node-domexception": "^1.0.0", 163 | "web-streams-polyfill": "^3.1.0" 164 | } 165 | }, 166 | "is-typedarray": { 167 | "version": "1.0.0", 168 | "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", 169 | "integrity": "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==", 170 | "dev": true 171 | }, 172 | "ms": { 173 | "version": "2.0.0", 174 | "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", 175 | "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", 176 | "dev": true 177 | }, 178 | "next-tick": { 179 | "version": "1.1.0", 180 | "resolved": "https://registry.npmjs.org/next-tick/-/next-tick-1.1.0.tgz", 181 | "integrity": "sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==", 182 | "dev": true 183 | }, 184 | "node-domexception": { 185 | "version": "1.0.0", 186 | "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", 187 | "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", 188 | "dev": true 189 | }, 190 | "node-gyp-build": { 191 | "version": "4.8.0", 192 | "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.0.tgz", 193 | "integrity": "sha512-u6fs2AEUljNho3EYTJNBfImO5QTo/J/1Etd+NVdCj7qWKUSN/bSLkZwhDv7I+w/MSC6qJ4cknepkAYykDdK8og==", 194 | "dev": true 195 | }, 196 | "prettier": { 197 | "version": "3.0.3", 198 | "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.0.3.tgz", 199 | "integrity": "sha512-L/4pUDMxcNa8R/EthV08Zt42WBO4h1rarVtK0K+QJG0X187OLo7l699jWw0GKuwzkPQ//jMFA/8Xm6Fh3J/DAg==", 200 | "dev": true 201 | }, 202 | "three": { 203 | "version": "0.94.0", 204 | "resolved": "https://registry.npmjs.org/three/-/three-0.94.0.tgz", 205 | "integrity": "sha512-UFyqFrb/CaTAHStYaPNxNeddNo/wlApRMJK0oIWjx5WMj/xhgXWKMRuAJ+leZBIA4wgeqifrbjKiwmNiysgMLg==", 206 | "dev": true 207 | }, 208 | "tstl": { 209 | "version": "2.5.13", 210 | "resolved": "https://registry.npmjs.org/tstl/-/tstl-2.5.13.tgz", 211 | "integrity": "sha512-h9wayHHFI5+yqt8iau0vqH96cTNhezhZ/Fk/hrIdpfkiMu3lg9nzyvMfs5bIdX51IVzZO6DudLqhkL/rVXpT6g==", 212 | "dev": true 213 | }, 214 | "type": { 215 | "version": "1.2.0", 216 | "resolved": "https://registry.npmjs.org/type/-/type-1.2.0.tgz", 217 | "integrity": "sha512-+5nt5AAniqsCnu2cEQQdpzCAh33kVx8n0VoFidKpB1dVVLAN/F+bgVOqOJqOnEnrhp222clB5p3vUlD+1QAnfg==", 218 | "dev": true 219 | }, 220 | "typedarray-to-buffer": { 221 | "version": "3.1.5", 222 | "resolved": "https://registry.npmjs.org/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz", 223 | "integrity": "sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q==", 224 | "dev": true, 225 | "requires": { 226 | "is-typedarray": "^1.0.0" 227 | } 228 | }, 229 | "typescript": { 230 | "version": "4.9.5", 231 | "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", 232 | "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", 233 | "dev": true 234 | }, 235 | "utf-8-validate": { 236 | "version": "5.0.10", 237 | "resolved": "https://registry.npmjs.org/utf-8-validate/-/utf-8-validate-5.0.10.tgz", 238 | "integrity": "sha512-Z6czzLq4u8fPOyx7TU6X3dvUZVvoJmxSQ+IcrlmagKhilxlhZgxPK6C5Jqbkw1IDUmFTM+cz9QDnnLTwDz/2gQ==", 239 | "dev": true, 240 | "requires": { 241 | "node-gyp-build": "^4.3.0" 242 | } 243 | }, 244 | "web-streams-polyfill": { 245 | "version": "3.3.2", 246 | "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.2.tgz", 247 | "integrity": "sha512-3pRGuxRF5gpuZc0W+EpwQRmCD7gRqcDOMt688KmdlDAgAyaB1XlN0zq2njfDNm44XVdIouE7pZ6GzbdyH47uIQ==", 248 | "dev": true 249 | }, 250 | "websocket": { 251 | "version": "1.0.34", 252 | "resolved": "https://registry.npmjs.org/websocket/-/websocket-1.0.34.tgz", 253 | "integrity": "sha512-PRDso2sGwF6kM75QykIesBijKSVceR6jL2G8NGYyq2XrItNC2P5/qL5XeR056GhA+Ly7JMFvJb9I312mJfmqnQ==", 254 | "dev": true, 255 | "requires": { 256 | "bufferutil": "^4.0.1", 257 | "debug": "^2.2.0", 258 | "es5-ext": "^0.10.50", 259 | "typedarray-to-buffer": "^3.1.5", 260 | "utf-8-validate": "^5.0.2", 261 | "yaeti": "^0.0.6" 262 | } 263 | }, 264 | "websocket-polyfill": { 265 | "version": "0.0.3", 266 | "resolved": "https://registry.npmjs.org/websocket-polyfill/-/websocket-polyfill-0.0.3.tgz", 267 | "integrity": "sha512-pF3kR8Uaoau78MpUmFfzbIRxXj9PeQrCuPepGE6JIsfsJ/o/iXr07Q2iQNzKSSblQJ0FiGWlS64N4pVSm+O3Dg==", 268 | "dev": true, 269 | "requires": { 270 | "tstl": "^2.0.7", 271 | "websocket": "^1.0.28" 272 | } 273 | }, 274 | "yaeti": { 275 | "version": "0.0.6", 276 | "resolved": "https://registry.npmjs.org/yaeti/-/yaeti-0.0.6.tgz", 277 | "integrity": "sha512-MvQa//+KcZCUkBTIC9blM+CU9J2GzuTytsOUwf2lidtvkx/6gnEp1QvJv34t9vdjhFmha/mUiNDbN0D0mJWdug==", 278 | "dev": true 279 | } 280 | } 281 | } 282 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@supalosa/chronodivide-bot", 3 | "version": "0.6.0", 4 | "description": "Example bot for Chrono Divide", 5 | "repository": "https://github.com/Supalosa/supalosa-chronodivide-bot", 6 | "main": "dist/main.js", 7 | "type": "module", 8 | "scripts": { 9 | "build": "tsc -p .", 10 | "watch": "tsc -p . -w", 11 | "start": "node . --es-module-specifier-resolution=node", 12 | "test": "echo \"Error: no test specified\" && exit 1" 13 | }, 14 | "license": "UNLICENSED", 15 | "devDependencies": { 16 | "@chronodivide/game-api": "^0.51.2", 17 | "@types/node": "^14.17.32", 18 | "prettier": "3.0.3", 19 | "typescript": "^4.3.5" 20 | }, 21 | "peerDependencies": { 22 | "@chronodivide/game-api": "^0.51.2" 23 | }, 24 | "dependencies": { 25 | "@datastructures-js/priority-queue": "^6.3.0", 26 | "@timohausmann/quadtree-ts": "2.2.2", 27 | "dotenv": "^16.3.1" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/bot/bot.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ApiEventType, 3 | Bot, 4 | GameApi, 5 | ApiEvent, 6 | ObjectType, 7 | FactoryType, 8 | Size, 9 | PlayerData, 10 | } from "@chronodivide/game-api"; 11 | 12 | import { determineMapBounds } from "./logic/map/map.js"; 13 | import { SectorCache } from "./logic/map/sector.js"; 14 | import { MissionController } from "./logic/mission/missionController.js"; 15 | import { QueueController } from "./logic/building/queueController.js"; 16 | import { MatchAwareness, MatchAwarenessImpl } from "./logic/awareness.js"; 17 | import { Countries, formatTimeDuration } from "./logic/common/utils.js"; 18 | import { TriggeredAttackMissionFactory } from "./logic/mission/missions/triggers/triggerManager.js"; 19 | import { createBaseMissionFactories } from "./logic/mission/missionFactories.js"; 20 | import { DynamicAttackMissionFactory } from "./logic/mission/missions/attackMission.js"; 21 | 22 | const DEBUG_STATE_UPDATE_INTERVAL_SECONDS = 6; 23 | 24 | // Number of ticks per second at the base speed. 25 | const NATURAL_TICK_RATE = 15; 26 | 27 | export enum BotDifficulty { 28 | Dynamic, // Original AI without triggers 29 | Easy, 30 | Medium, 31 | Hard, 32 | } 33 | 34 | export class SupalosaBot extends Bot { 35 | private tickRatio?: number; 36 | private knownMapBounds: Size | undefined; 37 | private missionController?: MissionController; 38 | private queueController: QueueController; 39 | private tickOfLastAttackOrder: number = 0; 40 | 41 | private matchAwareness: MatchAwareness | null = null; 42 | 43 | private didQuitGame: boolean = false; 44 | 45 | constructor( 46 | name: string, 47 | country: Countries, 48 | private difficulty: BotDifficulty, 49 | private tryAllyWith: string[] = [], 50 | ) { 51 | super(name, country); 52 | this.queueController = new QueueController(); 53 | } 54 | 55 | private createMissionFactories(game: GameApi, playerData: PlayerData) { 56 | const baseMissionFactories = createBaseMissionFactories(); 57 | if (this.difficulty === BotDifficulty.Dynamic) { 58 | return [...baseMissionFactories, new DynamicAttackMissionFactory()]; 59 | } else { 60 | return [...baseMissionFactories, new TriggeredAttackMissionFactory(game, playerData, this.difficulty)]; 61 | } 62 | } 63 | 64 | override onGameStart(game: GameApi) { 65 | const gameRate = game.getTickRate(); 66 | const botApm = 300; 67 | const botRate = botApm / 60; 68 | this.tickRatio = Math.ceil(gameRate / botRate); 69 | 70 | const myPlayer = game.getPlayerData(this.name); 71 | 72 | this.missionController = new MissionController( 73 | this.createMissionFactories(game, myPlayer), 74 | (message, sayInGame) => this.logBotStatus(message, sayInGame), 75 | ); 76 | 77 | this.knownMapBounds = determineMapBounds(game.mapApi); 78 | 79 | this.matchAwareness = new MatchAwarenessImpl( 80 | null, 81 | new SectorCache(game.mapApi, this.knownMapBounds), 82 | myPlayer.startLocation, 83 | (message, sayInGame) => this.logBotStatus(message, sayInGame), 84 | ); 85 | this.matchAwareness.onGameStart(game, myPlayer); 86 | 87 | this.logBotStatus(`Map bounds: ${this.knownMapBounds.width}, ${this.knownMapBounds.height}`); 88 | 89 | this.tryAllyWith 90 | .filter((playerName) => playerName !== this.name) 91 | .forEach((playerName) => this.actionsApi.toggleAlliance(playerName, true)); 92 | } 93 | 94 | override onGameTick(game: GameApi) { 95 | if (!this.matchAwareness) { 96 | return; 97 | } 98 | if (!this.missionController) { 99 | return; 100 | } 101 | if (this.didQuitGame) { 102 | return; 103 | } 104 | 105 | const threatCache = this.matchAwareness.getThreatCache(); 106 | 107 | if ((game.getCurrentTick() / NATURAL_TICK_RATE) % DEBUG_STATE_UPDATE_INTERVAL_SECONDS === 0) { 108 | this.updateDebugState(game); 109 | } 110 | 111 | if (game.getCurrentTick() % this.tickRatio! === 0) { 112 | const myPlayer = game.getPlayerData(this.name); 113 | 114 | this.matchAwareness.onAiUpdate(game, myPlayer); 115 | 116 | // hacky resign condition 117 | const armyUnits = game.getVisibleUnits(this.name, "self", (r) => r.isSelectableCombatant); 118 | const mcvUnits = game.getVisibleUnits( 119 | this.name, 120 | "self", 121 | (r) => !!r.deploysInto && game.getGeneralRules().baseUnit.includes(r.name), 122 | ); 123 | const productionBuildings = game.getVisibleUnits( 124 | this.name, 125 | "self", 126 | (r) => r.type == ObjectType.Building && r.factory != FactoryType.None, 127 | ); 128 | if (armyUnits.length == 0 && productionBuildings.length == 0 && mcvUnits.length == 0) { 129 | this.logBotStatus(`No army or production left, quitting.`); 130 | this.actionsApi.quitGame(); 131 | this.didQuitGame = true; 132 | } 133 | 134 | // Mission logic every 5 ticks 135 | if (this.gameApi.getCurrentTick() % 5 === 0) { 136 | this.missionController.onAiUpdate( 137 | game, 138 | this.productionApi, 139 | this.actionsApi, 140 | myPlayer, 141 | this.matchAwareness, 142 | ); 143 | } 144 | 145 | const unitTypeRequests = this.missionController.getRequestedUnitTypes(); 146 | 147 | // Build logic. 148 | this.queueController.onAiUpdate( 149 | game, 150 | this.productionApi, 151 | this.actionsApi, 152 | myPlayer, 153 | threatCache, 154 | unitTypeRequests, 155 | (message) => this.logBotStatus(message), 156 | ); 157 | } 158 | } 159 | 160 | private getHumanTimestamp(game: GameApi) { 161 | return formatTimeDuration(game.getCurrentTick() / NATURAL_TICK_RATE); 162 | } 163 | 164 | private logBotStatus(message: string, sayInGame: boolean = false) { 165 | if (!this.getDebugMode()) { 166 | return; 167 | } 168 | this.logger.info(message); 169 | if (sayInGame) { 170 | const timestamp = this.getHumanTimestamp(this.gameApi); 171 | this.actionsApi.sayAll(`${timestamp}: ${message}`); 172 | } 173 | } 174 | 175 | private updateDebugState(game: GameApi) { 176 | if (!this.getDebugMode()) { 177 | return; 178 | } 179 | // Update the global debug text. 180 | const myPlayer = game.getPlayerData(this.name); 181 | const harvesters = game.getVisibleUnits(this.name, "self", (r) => r.harvester).length; 182 | 183 | let globalDebugText = `Cash: ${myPlayer.credits} | Harvesters: ${harvesters}\n`; 184 | globalDebugText += this.queueController.getGlobalDebugText(this.gameApi, this.productionApi); 185 | globalDebugText += this.missionController?.getGlobalDebugText(this.gameApi); 186 | globalDebugText += this.matchAwareness?.getGlobalDebugText(); 187 | 188 | this.missionController?.updateDebugText(this.actionsApi); 189 | 190 | // Tag enemy units with IDs 191 | game.getVisibleUnits(this.name, "enemy").forEach((unitId) => { 192 | this.actionsApi.setUnitDebugText(unitId, unitId.toString()); 193 | }); 194 | 195 | this.actionsApi.setGlobalDebugText(globalDebugText); 196 | } 197 | 198 | override onGameEvent(ev: ApiEvent) { 199 | switch (ev.type) { 200 | case ApiEventType.ObjectDestroy: { 201 | // Add to the stalemate detection. 202 | if (ev.attackerInfo?.playerName == this.name) { 203 | this.tickOfLastAttackOrder += (this.gameApi.getCurrentTick() - this.tickOfLastAttackOrder) / 2; 204 | } 205 | break; 206 | } 207 | default: 208 | break; 209 | } 210 | } 211 | } 212 | -------------------------------------------------------------------------------- /src/bot/logic/awareness.ts: -------------------------------------------------------------------------------- 1 | import { GameApi, GameObjectData, ObjectType, PlayerData, UnitData, Vector2 } from "@chronodivide/game-api"; 2 | import { SectorCache } from "./map/sector"; 3 | import { GlobalThreat } from "./threat/threat"; 4 | import { calculateGlobalThreat } from "./threat/threatCalculator.js"; 5 | import { determineMapBounds, getDistanceBetweenPoints, getPointTowardsOtherPoint } from "./map/map.js"; 6 | import { Circle, Quadtree } from "@timohausmann/quadtree-ts"; 7 | import { ScoutingManager } from "./common/scout.js"; 8 | 9 | export type UnitPositionQuery = { x: number; y: number; unitId: number }; 10 | 11 | /** 12 | * The bot's understanding of the current state of the game. 13 | */ 14 | export interface MatchAwareness { 15 | /** 16 | * Returns the threat cache for the AI. 17 | */ 18 | getThreatCache(): GlobalThreat | null; 19 | 20 | /** 21 | * Returns the sector visibility cache. 22 | */ 23 | getSectorCache(): SectorCache; 24 | 25 | /** 26 | * Returns the enemy unit IDs in a certain radius of a point. 27 | * Warning: this may return non-combatant hostiles, such as neutral units. 28 | */ 29 | getHostilesNearPoint2d(point: Vector2, radius: number): UnitPositionQuery[]; 30 | 31 | /** 32 | * Returns the enemy unit IDs in a certain radius of a point. 33 | * Warning: this may return non-combatant hostiles, such as neutral units. 34 | */ 35 | getHostilesNearPoint(x: number, y: number, radius: number): UnitPositionQuery[]; 36 | 37 | /** 38 | * Returns the main rally point for the AI, which updates every few ticks. 39 | */ 40 | getMainRallyPoint(): Vector2; 41 | 42 | onGameStart(gameApi: GameApi, playerData: PlayerData): void; 43 | 44 | /** 45 | * Update the internal state of the Ai. 46 | * @param gameApi 47 | * @param playerData 48 | */ 49 | onAiUpdate(gameApi: GameApi, playerData: PlayerData): void; 50 | 51 | /** 52 | * True if the AI should initiate an attack. 53 | */ 54 | shouldAttack(): boolean; 55 | 56 | getScoutingManager(): ScoutingManager; 57 | 58 | getGlobalDebugText(): string | undefined; 59 | } 60 | 61 | const SECTORS_TO_UPDATE_PER_CYCLE = 8; 62 | 63 | const RALLY_POINT_UPDATE_INTERVAL_TICKS = 90; 64 | 65 | const THREAT_UPDATE_INTERVAL_TICKS = 30; 66 | 67 | type QTUnit = Circle; 68 | 69 | const rebuildQuadtree = (quadtree: Quadtree, units: GameObjectData[]) => { 70 | quadtree.clear(); 71 | units.forEach((unit) => { 72 | quadtree.insert(new Circle({ x: unit.tile.rx, y: unit.tile.ry, r: 1, data: unit.id })); 73 | }); 74 | }; 75 | 76 | export class MatchAwarenessImpl implements MatchAwareness { 77 | private _shouldAttack: boolean = false; 78 | 79 | private hostileQuadTree: Quadtree; 80 | private scoutingManager: ScoutingManager; 81 | 82 | constructor( 83 | private threatCache: GlobalThreat | null, 84 | private sectorCache: SectorCache, 85 | private mainRallyPoint: Vector2, 86 | private logger: (message: string, sayInGame?: boolean) => void, 87 | ) { 88 | const { width, height } = sectorCache.getMapBounds(); 89 | this.hostileQuadTree = new Quadtree({ width, height }); 90 | this.scoutingManager = new ScoutingManager(logger); 91 | } 92 | 93 | getHostilesNearPoint2d(point: Vector2, radius: number): UnitPositionQuery[] { 94 | return this.getHostilesNearPoint(point.x, point.y, radius); 95 | } 96 | 97 | getHostilesNearPoint(searchX: number, searchY: number, radius: number): UnitPositionQuery[] { 98 | const intersections = this.hostileQuadTree.retrieve(new Circle({ x: searchX, y: searchY, r: radius })); 99 | return intersections 100 | .map(({ x, y, data: unitId }) => ({ x, y, unitId: unitId! })) 101 | .filter(({ x, y }) => new Vector2(x, y).distanceTo(new Vector2(searchX, searchY)) <= radius) 102 | .filter(({ unitId }) => !!unitId); 103 | } 104 | 105 | getThreatCache(): GlobalThreat | null { 106 | return this.threatCache; 107 | } 108 | getSectorCache(): SectorCache { 109 | return this.sectorCache; 110 | } 111 | getMainRallyPoint(): Vector2 { 112 | return this.mainRallyPoint; 113 | } 114 | getScoutingManager(): ScoutingManager { 115 | return this.scoutingManager; 116 | } 117 | 118 | shouldAttack(): boolean { 119 | return this._shouldAttack; 120 | } 121 | 122 | private checkShouldAttack(threatCache: GlobalThreat, threatFactor: number) { 123 | let scaledGroundPower = threatCache.totalAvailableAntiGroundFirepower * 1.1; 124 | let scaledGroundThreat = 125 | (threatFactor * threatCache.totalOffensiveLandThreat + threatCache.totalDefensiveThreat) * 1.1; 126 | 127 | let scaledAirPower = threatCache.totalAvailableAirPower * 1.1; 128 | let scaledAirThreat = 129 | (threatFactor * threatCache.totalOffensiveAntiAirThreat + threatCache.totalDefensiveThreat) * 1.1; 130 | 131 | return scaledGroundPower > scaledGroundThreat || scaledAirPower > scaledAirThreat; 132 | } 133 | 134 | public onGameStart(gameApi: GameApi, playerData: PlayerData) { 135 | this.scoutingManager.onGameStart(gameApi, playerData, this.sectorCache); 136 | } 137 | 138 | onAiUpdate(game: GameApi, playerData: PlayerData): void { 139 | const sectorCache = this.sectorCache; 140 | 141 | sectorCache.updateSectors(game.getCurrentTick(), SECTORS_TO_UPDATE_PER_CYCLE, game.mapApi, playerData); 142 | 143 | this.scoutingManager.onAiUpdate(game, playerData, sectorCache); 144 | 145 | let updateRatio = sectorCache?.getSectorUpdateRatio(game.getCurrentTick() - game.getTickRate() * 60); 146 | if (updateRatio && updateRatio < 1.0) { 147 | this.logger(`${updateRatio * 100.0}% of sectors updated in last 60 seconds.`); 148 | } 149 | 150 | // Build the quadtree, if this is too slow we should consider doing this periodically. 151 | const hostileUnitIds = game.getVisibleUnits(playerData.name, "enemy"); 152 | try { 153 | const hostileUnits = hostileUnitIds 154 | .map((id) => game.getGameObjectData(id)) 155 | .filter( 156 | (gameObjectData: GameObjectData | undefined): gameObjectData is GameObjectData => 157 | gameObjectData !== undefined && (gameObjectData.hitPoints ?? 0) > 0, 158 | ); 159 | 160 | rebuildQuadtree(this.hostileQuadTree, hostileUnits); 161 | } catch (err) { 162 | // Hack. Will be fixed soon. 163 | console.error(`caught error`, hostileUnitIds); 164 | } 165 | 166 | if (game.getCurrentTick() % THREAT_UPDATE_INTERVAL_TICKS == 0) { 167 | let visibility = sectorCache?.getOverallVisibility(); 168 | if (visibility) { 169 | this.logger(`${Math.round(visibility * 1000.0) / 10}% of tiles visible. Calculating threat.`); 170 | // Update the global threat cache 171 | this.threatCache = calculateGlobalThreat(game, playerData, visibility); 172 | 173 | // As the game approaches 2 hours, be more willing to attack. (15 ticks per second) 174 | const gameLengthFactor = Math.max(0, 1.0 - game.getCurrentTick() / (15 * 7200.0)); 175 | this.logger(`Game length multiplier: ${gameLengthFactor}`); 176 | 177 | if (!this._shouldAttack) { 178 | // If not attacking, make it harder to switch to attack mode by multiplying the opponent's threat. 179 | this._shouldAttack = this.checkShouldAttack(this.threatCache, 1.25 * gameLengthFactor); 180 | if (this._shouldAttack) { 181 | this.logger(`Globally switched to attack mode.`); 182 | } 183 | } else { 184 | // If currently attacking, make it harder to switch to defence mode my dampening the opponent's threat. 185 | this._shouldAttack = this.checkShouldAttack(this.threatCache, 0.75 * gameLengthFactor); 186 | if (!this._shouldAttack) { 187 | this.logger(`Globally switched to defence mode.`); 188 | } 189 | } 190 | } 191 | } 192 | 193 | // Update rally point every few ticks. 194 | if (game.getCurrentTick() % RALLY_POINT_UPDATE_INTERVAL_TICKS === 0) { 195 | const enemyPlayers = game 196 | .getPlayers() 197 | .filter((p) => p !== playerData.name && !game.areAlliedPlayers(playerData.name, p)); 198 | const enemy = game.getPlayerData(enemyPlayers[0]); 199 | this.mainRallyPoint = getPointTowardsOtherPoint( 200 | game, 201 | playerData.startLocation, 202 | enemy.startLocation, 203 | 10, 204 | 10, 205 | 0, 206 | ); 207 | } 208 | } 209 | 210 | public getGlobalDebugText(): string | undefined { 211 | if (!this.threatCache) { 212 | return undefined; 213 | } 214 | return ( 215 | `Threat LAND: Them ${Math.round(this.threatCache.totalOffensiveLandThreat)}, us: ${Math.round( 216 | this.threatCache.totalAvailableAntiGroundFirepower, 217 | )}.\n` + 218 | `Threat DEFENSIVE: Them ${Math.round(this.threatCache.totalDefensiveThreat)}, us: ${Math.round( 219 | this.threatCache.totalDefensivePower, 220 | )}.\n` + 221 | `Threat AIR: Them ${Math.round(this.threatCache.totalOffensiveAirThreat)}, us: ${Math.round( 222 | this.threatCache.totalAvailableAntiAirFirepower, 223 | )}.` 224 | ); 225 | } 226 | } 227 | -------------------------------------------------------------------------------- /src/bot/logic/building/antiAirStaticDefence.ts: -------------------------------------------------------------------------------- 1 | import { GameApi, PlayerData, TechnoRules, Vector2 } from "@chronodivide/game-api"; 2 | import { getPointTowardsOtherPoint } from "../map/map.js"; 3 | import { GlobalThreat } from "../threat/threat.js"; 4 | import { AiBuildingRules, getDefaultPlacementLocation, numBuildingsOwnedOfType } from "./buildingRules.js"; 5 | 6 | export class AntiAirStaticDefence implements AiBuildingRules { 7 | constructor( 8 | private basePriority: number, 9 | private baseAmount: number, 10 | private airStrength: number, 11 | ) {} 12 | 13 | getPlacementLocation( 14 | game: GameApi, 15 | playerData: PlayerData, 16 | technoRules: TechnoRules, 17 | ): { rx: number; ry: number } | undefined { 18 | // Prefer front towards enemy. 19 | let startLocation = playerData.startLocation; 20 | let players = game.getPlayers(); 21 | let enemyFacingLocationCandidates: Vector2[] = []; 22 | for (let i = 0; i < players.length; ++i) { 23 | let playerName = players[i]; 24 | if (playerName == playerData.name) { 25 | continue; 26 | } 27 | let enemyPlayer = game.getPlayerData(playerName); 28 | enemyFacingLocationCandidates.push( 29 | getPointTowardsOtherPoint(game, startLocation, enemyPlayer.startLocation, 4, 16, 1.5), 30 | ); 31 | } 32 | let selectedLocation = 33 | enemyFacingLocationCandidates[Math.floor(game.generateRandom() * enemyFacingLocationCandidates.length)]; 34 | return getDefaultPlacementLocation(game, playerData, selectedLocation, technoRules, false, 0); 35 | } 36 | 37 | getPriority( 38 | game: GameApi, 39 | playerData: PlayerData, 40 | technoRules: TechnoRules, 41 | threatCache: GlobalThreat | null, 42 | ): number { 43 | if (threatCache) { 44 | let denominator = threatCache.totalAvailableAntiAirFirepower + this.airStrength; 45 | if (threatCache.totalOffensiveAirThreat > denominator * 1.1) { 46 | return this.basePriority * (threatCache.totalOffensiveAirThreat / Math.max(1, denominator)); 47 | } else { 48 | return 0; 49 | } 50 | } 51 | const strengthPerCost = (this.airStrength / technoRules.cost) * 1000; 52 | const numOwned = numBuildingsOwnedOfType(game, playerData, technoRules); 53 | return this.basePriority * (1.0 - numOwned / this.baseAmount) * strengthPerCost; 54 | } 55 | 56 | getMaxCount( 57 | game: GameApi, 58 | playerData: PlayerData, 59 | technoRules: TechnoRules, 60 | threatCache: GlobalThreat | null, 61 | ): number | null { 62 | return null; 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/bot/logic/building/antiGroundStaticDefence.ts: -------------------------------------------------------------------------------- 1 | import { GameApi, PlayerData, TechnoRules, Vector2 } from "@chronodivide/game-api"; 2 | import { getPointTowardsOtherPoint } from "../map/map.js"; 3 | import { GlobalThreat } from "../threat/threat.js"; 4 | import { AiBuildingRules, getDefaultPlacementLocation, numBuildingsOwnedOfType } from "./buildingRules.js"; 5 | import { getStaticDefencePlacement } from "./common.js"; 6 | 7 | export class AntiGroundStaticDefence implements AiBuildingRules { 8 | constructor( 9 | private basePriority: number, 10 | private baseAmount: number, 11 | private groundStrength: number, 12 | private limit: number, 13 | ) {} 14 | 15 | getPlacementLocation( 16 | game: GameApi, 17 | playerData: PlayerData, 18 | technoRules: TechnoRules, 19 | ): { rx: number; ry: number } | undefined { 20 | return getStaticDefencePlacement(game, playerData, technoRules); 21 | } 22 | 23 | getPriority( 24 | game: GameApi, 25 | playerData: PlayerData, 26 | technoRules: TechnoRules, 27 | threatCache: GlobalThreat | null, 28 | ): number { 29 | const numOwned = numBuildingsOwnedOfType(game, playerData, technoRules); 30 | if (numOwned >= this.limit) { 31 | return 0; 32 | } 33 | // If the enemy's ground power is increasing we should try to keep up. 34 | if (threatCache) { 35 | let denominator = 36 | threatCache.totalAvailableAntiGroundFirepower + threatCache.totalDefensivePower + this.groundStrength; 37 | if (threatCache.totalOffensiveLandThreat > denominator * 1.1) { 38 | return this.basePriority * (threatCache.totalOffensiveLandThreat / Math.max(1, denominator)); 39 | } else { 40 | return 0; 41 | } 42 | } 43 | const strengthPerCost = (this.groundStrength / technoRules.cost) * 1000; 44 | return this.basePriority * (1.0 - numOwned / this.baseAmount) * strengthPerCost; 45 | } 46 | 47 | getMaxCount( 48 | game: GameApi, 49 | playerData: PlayerData, 50 | technoRules: TechnoRules, 51 | threatCache: GlobalThreat | null, 52 | ): number | null { 53 | return null; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/bot/logic/building/artilleryUnit.ts: -------------------------------------------------------------------------------- 1 | import { GameApi, GameMath, PlayerData, TechnoRules } from "@chronodivide/game-api"; 2 | import { GlobalThreat } from "../threat/threat.js"; 3 | import { AiBuildingRules, numBuildingsOwnedOfType } from "./buildingRules.js"; 4 | 5 | export class ArtilleryUnit implements AiBuildingRules { 6 | constructor( 7 | private basePriority: number, 8 | private artilleryPower: number, 9 | private antiGroundPower: number, 10 | private baseAmount: number, 11 | ) {} 12 | 13 | getPlacementLocation( 14 | game: GameApi, 15 | playerData: PlayerData, 16 | technoRules: TechnoRules, 17 | ): { rx: number; ry: number } | undefined { 18 | return undefined; 19 | } 20 | 21 | getPriority( 22 | game: GameApi, 23 | playerData: PlayerData, 24 | technoRules: TechnoRules, 25 | threatCache: GlobalThreat | null, 26 | ): number { 27 | // Units aren't built automatically, but are instead requested by missions. 28 | return 0; 29 | } 30 | 31 | getMaxCount( 32 | game: GameApi, 33 | playerData: PlayerData, 34 | technoRules: TechnoRules, 35 | threatCache: GlobalThreat | null, 36 | ): number | null { 37 | return null; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/bot/logic/building/basicAirUnit.ts: -------------------------------------------------------------------------------- 1 | import { GameApi, GameMath, PlayerData, TechnoRules } from "@chronodivide/game-api"; 2 | import { GlobalThreat } from "../threat/threat.js"; 3 | import { AiBuildingRules, getDefaultPlacementLocation, numBuildingsOwnedOfType } from "./buildingRules.js"; 4 | 5 | export class BasicAirUnit implements AiBuildingRules { 6 | constructor( 7 | private basePriority: number, 8 | private baseAmount: number, 9 | private antiGroundPower: number = 1, // boolean for now, but will eventually be used in weighting. 10 | private antiAirPower: number = 0, 11 | ) {} 12 | 13 | getPlacementLocation( 14 | game: GameApi, 15 | playerData: PlayerData, 16 | technoRules: TechnoRules, 17 | ): { rx: number; ry: number } | undefined { 18 | return undefined; 19 | } 20 | 21 | getPriority( 22 | game: GameApi, 23 | playerData: PlayerData, 24 | technoRules: TechnoRules, 25 | threatCache: GlobalThreat | null, 26 | ): number { 27 | // Units aren't built automatically, but are instead requested by missions. 28 | return 0; 29 | } 30 | 31 | getMaxCount( 32 | game: GameApi, 33 | playerData: PlayerData, 34 | technoRules: TechnoRules, 35 | threatCache: GlobalThreat | null, 36 | ): number | null { 37 | return null; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/bot/logic/building/basicBuilding.ts: -------------------------------------------------------------------------------- 1 | import { GameApi, PlayerData, TechnoRules } from "@chronodivide/game-api"; 2 | import { AiBuildingRules, getDefaultPlacementLocation, numBuildingsOwnedOfType } from "./buildingRules.js"; 3 | import { GlobalThreat } from "../threat/threat.js"; 4 | 5 | export class BasicBuilding implements AiBuildingRules { 6 | constructor( 7 | protected basePriority: number, 8 | protected maxNeeded: number, 9 | protected onlyBuildWhenFloatingCreditsAmount?: number, 10 | ) {} 11 | 12 | getPlacementLocation( 13 | game: GameApi, 14 | playerData: PlayerData, 15 | technoRules: TechnoRules, 16 | ): { rx: number; ry: number } | undefined { 17 | return getDefaultPlacementLocation(game, playerData, playerData.startLocation, technoRules); 18 | } 19 | 20 | getPriority( 21 | game: GameApi, 22 | playerData: PlayerData, 23 | technoRules: TechnoRules, 24 | threatCache: GlobalThreat | null, 25 | ): number { 26 | const numOwned = numBuildingsOwnedOfType(game, playerData, technoRules); 27 | const calcMaxCount = this.getMaxCount(game, playerData, technoRules, threatCache); 28 | if (numOwned >= (calcMaxCount ?? this.maxNeeded)) { 29 | return -100; 30 | } 31 | 32 | const priority = this.basePriority * (1.0 - numOwned / this.maxNeeded); 33 | 34 | if (this.onlyBuildWhenFloatingCreditsAmount && playerData.credits < this.onlyBuildWhenFloatingCreditsAmount) { 35 | return priority * (playerData.credits / this.onlyBuildWhenFloatingCreditsAmount); 36 | } 37 | 38 | return priority; 39 | } 40 | 41 | getMaxCount( 42 | game: GameApi, 43 | playerData: PlayerData, 44 | technoRules: TechnoRules, 45 | threatCache: GlobalThreat | null, 46 | ): number | null { 47 | return this.maxNeeded; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/bot/logic/building/basicGroundUnit.ts: -------------------------------------------------------------------------------- 1 | import { GameApi, GameMath, PlayerData, TechnoRules } from "@chronodivide/game-api"; 2 | import { GlobalThreat } from "../threat/threat.js"; 3 | import { AiBuildingRules, getDefaultPlacementLocation, numBuildingsOwnedOfType } from "./buildingRules.js"; 4 | 5 | export class BasicGroundUnit implements AiBuildingRules { 6 | constructor( 7 | protected basePriority: number, 8 | protected baseAmount: number, 9 | protected antiGroundPower: number = 1, // boolean for now, but will eventually be used in weighting. 10 | protected antiAirPower: number = 0, 11 | ) {} 12 | 13 | getPlacementLocation( 14 | game: GameApi, 15 | playerData: PlayerData, 16 | technoRules: TechnoRules, 17 | ): { rx: number; ry: number } | undefined { 18 | return undefined; 19 | } 20 | 21 | getPriority( 22 | game: GameApi, 23 | playerData: PlayerData, 24 | technoRules: TechnoRules, 25 | threatCache: GlobalThreat | null, 26 | ): number { 27 | // Units aren't built automatically, but are instead requested by missions. 28 | return 0; 29 | } 30 | 31 | getMaxCount( 32 | game: GameApi, 33 | playerData: PlayerData, 34 | technoRules: TechnoRules, 35 | threatCache: GlobalThreat | null, 36 | ): number | null { 37 | return null; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/bot/logic/building/buildingRules.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BuildingPlacementData, 3 | GameApi, 4 | GameMath, 5 | LandType, 6 | ObjectType, 7 | PlayerData, 8 | Rectangle, 9 | Size, 10 | TechnoRules, 11 | Tile, 12 | Vector2, 13 | } from "@chronodivide/game-api"; 14 | import { GlobalThreat } from "../threat/threat.js"; 15 | import { AntiGroundStaticDefence } from "./antiGroundStaticDefence.js"; 16 | import { ArtilleryUnit } from "./artilleryUnit.js"; 17 | import { BasicAirUnit } from "./basicAirUnit.js"; 18 | import { BasicBuilding } from "./basicBuilding.js"; 19 | import { BasicGroundUnit } from "./basicGroundUnit.js"; 20 | import { PowerPlant } from "./powerPlant.js"; 21 | import { ResourceCollectionBuilding } from "./resourceCollectionBuilding.js"; 22 | import { Harvester } from "./harvester.js"; 23 | import { uniqBy } from "../common/utils.js"; 24 | import { AntiAirStaticDefence } from "./antiAirStaticDefence.js"; 25 | 26 | export interface AiBuildingRules { 27 | getPriority( 28 | game: GameApi, 29 | playerData: PlayerData, 30 | technoRules: TechnoRules, 31 | threatCache: GlobalThreat | null, 32 | ): number; 33 | 34 | getPlacementLocation( 35 | game: GameApi, 36 | playerData: PlayerData, 37 | technoRules: TechnoRules, 38 | ): { rx: number; ry: number } | undefined; 39 | 40 | getMaxCount( 41 | game: GameApi, 42 | playerData: PlayerData, 43 | technoRules: TechnoRules, 44 | threatCache: GlobalThreat | null, 45 | ): number | null; 46 | } 47 | 48 | export function numBuildingsOwnedOfType(game: GameApi, playerData: PlayerData, technoRules: TechnoRules): number { 49 | return game.getVisibleUnits(playerData.name, "self", (r) => r == technoRules).length; 50 | } 51 | 52 | export function numBuildingsOwnedOfName(game: GameApi, playerData: PlayerData, name: string): number { 53 | return game.getVisibleUnits(playerData.name, "self", (r) => r.name === name).length; 54 | } 55 | 56 | /** 57 | * Computes a rect 'centered' around a structure of a certain size with an additional radius (`adjacent`). 58 | * The radius is optionally expanded by the size of the new building. 59 | * 60 | * This is essentially the candidate placement around a given structure. 61 | * 62 | * @param point Top-left location of the inner rect. 63 | * @param t Size of the inner rect. 64 | * @param adjacent Amount to expand the building's inner rect by (so buildings must be adjacent by this many tiles) 65 | * @param newBuildingSize? Size of the new building 66 | * @returns 67 | */ 68 | function computeAdjacentRect(point: Vector2, t: Size, adjacent: number, newBuildingSize?: Size): Rectangle { 69 | return { 70 | x: point.x - adjacent - (newBuildingSize?.width || 0), 71 | y: point.y - adjacent - (newBuildingSize?.height || 0), 72 | width: t.width + 2 * adjacent + (newBuildingSize?.width || 0), 73 | height: t.height + 2 * adjacent + (newBuildingSize?.height || 0), 74 | }; 75 | } 76 | 77 | function getAdjacentTiles(game: GameApi, range: Rectangle, onWater: boolean) { 78 | // use the bulk API to get all tiles from the baseTile to the (baseTile + range) 79 | const adjacentTiles = game.mapApi 80 | .getTilesInRect(range) 81 | .filter((tile) => !onWater || tile.landType === LandType.Water); 82 | return adjacentTiles; 83 | } 84 | 85 | export function getAdjacencyTiles( 86 | game: GameApi, 87 | playerData: PlayerData, 88 | technoRules: TechnoRules, 89 | onWater: boolean, 90 | minimumSpace: number, 91 | ): Tile[] { 92 | const placementRules = game.getBuildingPlacementData(technoRules.name); 93 | const { width: newBuildingWidth, height: newBuildingHeight } = placementRules.foundation; 94 | const tiles = []; 95 | const buildings = game.getVisibleUnits(playerData.name, "self", (r: TechnoRules) => r.type === ObjectType.Building); 96 | const removedTiles = new Set(); 97 | for (let buildingId of buildings) { 98 | const building = game.getUnitData(buildingId); 99 | if (!building?.rules?.baseNormal) { 100 | // This building is not considered for adjacency checks. 101 | continue; 102 | } 103 | const { foundation, tile } = building; 104 | const buildingBase = new Vector2(tile.rx, tile.ry); 105 | const buildingSize = { 106 | width: foundation?.width, 107 | height: foundation?.height, 108 | }; 109 | const range = computeAdjacentRect(buildingBase, buildingSize, technoRules.adjacent, placementRules.foundation); 110 | const adjacentTiles = getAdjacentTiles(game, range, onWater); 111 | if (adjacentTiles.length === 0) { 112 | continue; 113 | } 114 | tiles.push(...adjacentTiles); 115 | 116 | // Prevent placing the new building on tiles that would cause it to overlap with this building. 117 | const modifiedBase = new Vector2( 118 | buildingBase.x - (newBuildingWidth - 1), 119 | buildingBase.y - (newBuildingHeight - 1), 120 | ); 121 | const modifiedSize = { 122 | width: buildingSize.width + (newBuildingWidth - 1), 123 | height: buildingSize.height + (newBuildingHeight - 1), 124 | }; 125 | const blockedRect = computeAdjacentRect(modifiedBase, modifiedSize, minimumSpace); 126 | const buildingTiles = adjacentTiles.filter((tile) => { 127 | return ( 128 | tile.rx >= blockedRect.x && 129 | tile.rx < blockedRect.x + blockedRect.width && 130 | tile.ry >= blockedRect.y && 131 | tile.ry < blockedRect.y + blockedRect.height 132 | ); 133 | }); 134 | buildingTiles.forEach((buildingTile) => removedTiles.add(buildingTile.id)); 135 | } 136 | // Remove duplicate tiles. 137 | const withDuplicatesRemoved = uniqBy(tiles, (tile) => tile.id); 138 | // Remove tiles containing buildings and potentially area around them removed as well. 139 | return withDuplicatesRemoved.filter((tile) => !removedTiles.has(tile.id)); 140 | } 141 | 142 | function getTileDistances(startPoint: Vector2, tiles: Tile[]) { 143 | return tiles 144 | .map((tile) => ({ 145 | tile, 146 | distance: distance(tile.rx, tile.ry, startPoint.x, startPoint.y), 147 | })) 148 | .sort((a, b) => { 149 | return a.distance - b.distance; 150 | }); 151 | } 152 | 153 | function distance(x1: number, y1: number, x2: number, y2: number) { 154 | var dx = x1 - x2; 155 | var dy = y1 - y2; 156 | let tmp = dx * dx + dy * dy; 157 | if (0 === tmp) { 158 | return 0; 159 | } 160 | return GameMath.sqrt(tmp); 161 | } 162 | 163 | export function getDefaultPlacementLocation( 164 | game: GameApi, 165 | playerData: PlayerData, 166 | idealPoint: Vector2, 167 | technoRules: TechnoRules, 168 | onWater: boolean = false, 169 | minSpace: number = 1, 170 | ): { rx: number; ry: number } | undefined { 171 | // Closest possible location near `startPoint`. 172 | const size: BuildingPlacementData = game.getBuildingPlacementData(technoRules.name); 173 | if (!size) { 174 | return undefined; 175 | } 176 | const tiles = getAdjacencyTiles(game, playerData, technoRules, onWater, minSpace); 177 | const tileDistances = getTileDistances(idealPoint, tiles); 178 | 179 | for (let tileDistance of tileDistances) { 180 | if (tileDistance.tile && game.canPlaceBuilding(playerData.name, technoRules.name, tileDistance.tile)) { 181 | return tileDistance.tile; 182 | } 183 | } 184 | return undefined; 185 | } 186 | 187 | // Priority 0 = don't build. 188 | export type TechnoRulesWithPriority = { unit: TechnoRules; priority: number }; 189 | 190 | export const DEFAULT_BUILDING_PRIORITY = 0; 191 | 192 | export const BUILDING_NAME_TO_RULES = new Map([ 193 | // Allied 194 | ["GAPOWR", new PowerPlant()], 195 | ["GAREFN", new ResourceCollectionBuilding(10, 3)], // Refinery 196 | ["GAWEAP", new BasicBuilding(15, 1)], // War Factory 197 | ["GAPILE", new BasicBuilding(12, 1)], // Barracks 198 | ["CMIN", new Harvester(15, 4, 2)], // Chrono Miner 199 | ["GADEPT", new BasicBuilding(1, 1, 10000)], // Repair Depot 200 | ["GAAIRC", new BasicBuilding(10, 1, 500)], // Airforce Command 201 | ["AMRADR", new BasicBuilding(10, 1, 500)], // Airforce Command (USA) 202 | 203 | ["GATECH", new BasicBuilding(20, 1, 4000)], // Allied Battle Lab 204 | ["GAYARD", new BasicBuilding(0, 0, 0)], // Naval Yard, disabled 205 | 206 | ["GAPILL", new AntiGroundStaticDefence(2, 1, 5, 5)], // Pillbox 207 | ["ATESLA", new AntiGroundStaticDefence(2, 1, 10, 3)], // Prism Cannon 208 | ["NASAM", new AntiAirStaticDefence(2, 1, 5)], // Patriot Missile 209 | ["GAWALL", new AntiGroundStaticDefence(0, 0, 0, 0)], // Walls 210 | 211 | ["E1", new BasicGroundUnit(2, 2, 0.2, 0)], // GI 212 | ["ENGINEER", new BasicGroundUnit(1, 0, 0)], // Engineer 213 | ["MTNK", new BasicGroundUnit(10, 3, 2, 0)], // Grizzly Tank 214 | ["MGTK", new BasicGroundUnit(10, 1, 2.5, 0)], // Mirage Tank 215 | ["FV", new BasicGroundUnit(5, 2, 0.5, 1)], // IFV 216 | ["JUMPJET", new BasicAirUnit(10, 1, 1, 1)], // Rocketeer 217 | ["ORCA", new BasicAirUnit(7, 1, 2, 0)], // Rocketeer 218 | ["SREF", new ArtilleryUnit(10, 5, 3, 3)], // Prism Tank 219 | ["CLEG", new BasicGroundUnit(0, 0)], // Chrono Legionnaire (Disabled - we don't handle the warped out phase properly and it tends to bug both bots out) 220 | ["SHAD", new BasicGroundUnit(0, 0)], // Nighthawk (Disabled) 221 | 222 | // Soviet 223 | ["NAPOWR", new PowerPlant()], 224 | ["NAREFN", new ResourceCollectionBuilding(10, 3)], // Refinery 225 | ["NAWEAP", new BasicBuilding(15, 1)], // War Factory 226 | ["NAHAND", new BasicBuilding(12, 1)], // Barracks 227 | ["HARV", new Harvester(15, 4, 2)], // War Miner 228 | ["NADEPT", new BasicBuilding(1, 1, 10000)], // Repair Depot 229 | ["NARADR", new BasicBuilding(10, 1, 500)], // Radar 230 | ["NANRCT", new PowerPlant()], // Nuclear Reactor 231 | ["NAYARD", new BasicBuilding(0, 0, 0)], // Naval Yard, disabled 232 | 233 | ["NATECH", new BasicBuilding(20, 1, 4000)], // Soviet Battle Lab 234 | 235 | ["NALASR", new AntiGroundStaticDefence(2, 1, 5, 5)], // Sentry Gun 236 | ["NAFLAK", new AntiAirStaticDefence(2, 1, 5)], // Flak Cannon 237 | ["TESLA", new AntiGroundStaticDefence(2, 1, 10, 3)], // Tesla Coil 238 | ["NAWALL", new AntiGroundStaticDefence(0, 0, 0, 0)], // Walls 239 | 240 | ["E2", new BasicGroundUnit(2, 2, 0.2, 0)], // Conscript 241 | ["SENGINEER", new BasicGroundUnit(1, 0, 0)], // Soviet Engineer 242 | ["FLAKT", new BasicGroundUnit(2, 2, 0.1, 0.3)], // Flak Trooper 243 | ["YURI", new BasicGroundUnit(1, 1, 1, 0)], // Yuri 244 | ["DOG", new BasicGroundUnit(1, 1, 0, 0)], // Soviet Attack Dog 245 | ["HTNK", new BasicGroundUnit(10, 3, 3, 0)], // Rhino Tank 246 | ["APOC", new BasicGroundUnit(6, 1, 5, 0)], // Apocalypse Tank 247 | ["HTK", new BasicGroundUnit(5, 2, 0.33, 1.5)], // Flak Track 248 | ["ZEP", new BasicAirUnit(5, 1, 5, 1)], // Kirov 249 | ["V3", new ArtilleryUnit(9, 10, 0, 3)], // V3 Rocket Launcher 250 | ]); 251 | -------------------------------------------------------------------------------- /src/bot/logic/building/common.ts: -------------------------------------------------------------------------------- 1 | import { GameApi, PlayerData, TechnoRules, Vector2 } from "@chronodivide/game-api"; 2 | import { getPointTowardsOtherPoint } from "../map/map.js"; 3 | import { getDefaultPlacementLocation } from "./buildingRules.js"; 4 | 5 | export const getStaticDefencePlacement = (game: GameApi, playerData: PlayerData, technoRules: TechnoRules) => { 6 | // Prefer front towards enemy. 7 | const { startLocation, name: currentName } = playerData; 8 | const allNames = game.getPlayers(); 9 | // Create a list of positions that point roughly towards hostile player start locatoins. 10 | const candidates = allNames 11 | .filter((otherName) => otherName !== currentName && !game.areAlliedPlayers(otherName, currentName)) 12 | .map((otherName) => { 13 | const enemyPlayer = game.getPlayerData(otherName); 14 | return getPointTowardsOtherPoint(game, startLocation, enemyPlayer.startLocation, 4, 16, 1.5); 15 | }); 16 | if (candidates.length === 0) { 17 | return undefined; 18 | } 19 | const selectedLocation = candidates[Math.floor(game.generateRandom() * candidates.length)]; 20 | return getDefaultPlacementLocation(game, playerData, selectedLocation, technoRules, false, 2); 21 | }; 22 | -------------------------------------------------------------------------------- /src/bot/logic/building/harvester.ts: -------------------------------------------------------------------------------- 1 | import { GameApi, PlayerData, TechnoRules } from "@chronodivide/game-api"; 2 | import { GlobalThreat } from "../threat/threat.js"; 3 | import { BasicGroundUnit } from "./basicGroundUnit.js"; 4 | 5 | const IDEAL_HARVESTERS_PER_REFINERY = 2; 6 | const MAX_HARVESTERS_PER_REFINERY = 4; 7 | 8 | export class Harvester extends BasicGroundUnit { 9 | constructor( 10 | basePriority: number, 11 | baseAmount: number, 12 | private minNeeded: number, 13 | ) { 14 | super(basePriority, baseAmount, 0, 0); 15 | } 16 | 17 | // Priority goes up when we have fewer than this many refineries. 18 | getPriority( 19 | game: GameApi, 20 | playerData: PlayerData, 21 | technoRules: TechnoRules, 22 | threatCache: GlobalThreat | null, 23 | ): number { 24 | const refineries = game.getVisibleUnits(playerData.name, "self", (r) => r.refinery).length; 25 | const harvesters = game.getVisibleUnits(playerData.name, "self", (r) => r.harvester).length; 26 | 27 | const boost = harvesters < this.minNeeded ? 3 : harvesters > refineries * MAX_HARVESTERS_PER_REFINERY ? 0 : 1; 28 | 29 | return this.basePriority * (refineries / Math.max(harvesters / IDEAL_HARVESTERS_PER_REFINERY, 1)) * boost; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/bot/logic/building/powerPlant.ts: -------------------------------------------------------------------------------- 1 | import { GameApi, PlayerData, TechnoRules } from "@chronodivide/game-api"; 2 | import { AiBuildingRules, getDefaultPlacementLocation } from "./buildingRules.js"; 3 | import { GlobalThreat } from "../threat/threat.js"; 4 | 5 | export class PowerPlant implements AiBuildingRules { 6 | getPlacementLocation( 7 | game: GameApi, 8 | playerData: PlayerData, 9 | technoRules: TechnoRules 10 | ): { rx: number; ry: number } | undefined { 11 | return getDefaultPlacementLocation(game, playerData, playerData.startLocation, technoRules); 12 | } 13 | 14 | getPriority(game: GameApi, playerData: PlayerData, technoRules: TechnoRules): number { 15 | if (playerData.power.total < playerData.power.drain) { 16 | return 100; 17 | } else if (playerData.power.total < playerData.power.drain + technoRules.power / 2) { 18 | return 20; 19 | } else { 20 | return 0; 21 | } 22 | } 23 | 24 | getMaxCount( 25 | game: GameApi, 26 | playerData: PlayerData, 27 | technoRules: TechnoRules, 28 | threatCache: GlobalThreat | null 29 | ): number | null { 30 | return null; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/bot/logic/building/queueController.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ActionsApi, 3 | GameApi, 4 | PlayerData, 5 | ProductionApi, 6 | QueueStatus, 7 | QueueType, 8 | TechnoRules, 9 | } from "@chronodivide/game-api"; 10 | import { GlobalThreat } from "../threat/threat"; 11 | import { 12 | TechnoRulesWithPriority, 13 | BUILDING_NAME_TO_RULES, 14 | DEFAULT_BUILDING_PRIORITY, 15 | getDefaultPlacementLocation, 16 | } from "./buildingRules.js"; 17 | import { DebugLogger } from "../common/utils"; 18 | 19 | export const QUEUES = [ 20 | QueueType.Structures, 21 | QueueType.Armory, 22 | QueueType.Infantry, 23 | QueueType.Vehicles, 24 | QueueType.Aircrafts, 25 | QueueType.Ships, 26 | ]; 27 | 28 | export const queueTypeToName = (queue: QueueType) => { 29 | switch (queue) { 30 | case QueueType.Structures: 31 | return "Structures"; 32 | case QueueType.Armory: 33 | return "Armory"; 34 | case QueueType.Infantry: 35 | return "Infantry"; 36 | case QueueType.Vehicles: 37 | return "Vehicles"; 38 | case QueueType.Aircrafts: 39 | return "Aircrafts"; 40 | case QueueType.Ships: 41 | return "Ships"; 42 | default: 43 | return "Unknown"; 44 | } 45 | }; 46 | 47 | type QueueState = { 48 | queue: QueueType; 49 | /** sorted in ascending order (last item is the topItem) */ 50 | items: TechnoRulesWithPriority[]; 51 | topItem: TechnoRulesWithPriority | undefined; 52 | }; 53 | 54 | const REPAIR_CHECK_INTERVAL = 30; 55 | 56 | export class QueueController { 57 | private queueStates: QueueState[] = []; 58 | private lastRepairCheckAt = 0; 59 | 60 | constructor() {} 61 | 62 | public onAiUpdate( 63 | game: GameApi, 64 | productionApi: ProductionApi, 65 | actionsApi: ActionsApi, 66 | playerData: PlayerData, 67 | threatCache: GlobalThreat | null, 68 | unitTypeRequests: Map, 69 | logger: (message: string) => void, 70 | ) { 71 | this.queueStates = QUEUES.map((queueType) => { 72 | const options = productionApi.getAvailableObjects(queueType); 73 | const items = this.getPrioritiesForBuildingOptions( 74 | game, 75 | options, 76 | threatCache, 77 | playerData, 78 | unitTypeRequests, 79 | logger, 80 | ); 81 | const topItem = items.length > 0 ? items[items.length - 1] : undefined; 82 | return { 83 | queue: queueType, 84 | items, 85 | // only if the top item has a priority above zero 86 | topItem: topItem && topItem.priority > 0 ? topItem : undefined, 87 | }; 88 | }); 89 | const totalWeightAcrossQueues = this.queueStates 90 | .map((decision) => decision.topItem?.priority!) 91 | .reduce((pV, cV) => pV + cV, 0); 92 | const totalCostAcrossQueues = this.queueStates 93 | .map((decision) => decision.topItem?.unit.cost!) 94 | .reduce((pV, cV) => pV + cV, 0); 95 | 96 | this.queueStates.forEach((decision) => { 97 | this.updateBuildQueue( 98 | game, 99 | productionApi, 100 | actionsApi, 101 | playerData, 102 | threatCache, 103 | decision.queue, 104 | decision.topItem, 105 | totalWeightAcrossQueues, 106 | totalCostAcrossQueues, 107 | logger, 108 | ); 109 | }); 110 | 111 | // Repair is simple - just repair everything that's damaged. 112 | if (playerData.credits > 0 && game.getCurrentTick() > this.lastRepairCheckAt + REPAIR_CHECK_INTERVAL) { 113 | game.getVisibleUnits(playerData.name, "self", (r) => r.repairable).forEach((unitId) => { 114 | const unit = game.getUnitData(unitId); 115 | if (!unit || !unit.hitPoints || !unit.maxHitPoints || unit.hasWrenchRepair) { 116 | return; 117 | } 118 | if (unit.hitPoints < unit.maxHitPoints) { 119 | actionsApi.toggleRepairWrench(unitId); 120 | } 121 | }); 122 | this.lastRepairCheckAt = game.getCurrentTick(); 123 | } 124 | } 125 | 126 | private updateBuildQueue( 127 | game: GameApi, 128 | productionApi: ProductionApi, 129 | actionsApi: ActionsApi, 130 | playerData: PlayerData, 131 | threatCache: GlobalThreat | null, 132 | queueType: QueueType, 133 | decision: TechnoRulesWithPriority | undefined, 134 | totalWeightAcrossQueues: number, 135 | totalCostAcrossQueues: number, 136 | logger: (message: string) => void, 137 | ): void { 138 | const myCredits = playerData.credits; 139 | 140 | const queueData = productionApi.getQueueData(queueType); 141 | if (queueData.status == QueueStatus.Idle) { 142 | // Start building the decided item. 143 | if (decision !== undefined) { 144 | logger(`Decision (${queueTypeToName(queueType)}): ${decision.unit.name}`); 145 | actionsApi.queueForProduction(queueType, decision.unit.name, decision.unit.type, 1); 146 | } 147 | } else if (queueData.status == QueueStatus.Ready && queueData.items.length > 0) { 148 | // Consider placing it. 149 | const objectReady: TechnoRules = queueData.items[0].rules; 150 | if (queueType == QueueType.Structures || queueType == QueueType.Armory) { 151 | let location: { rx: number; ry: number } | undefined = this.getBestLocationForStructure( 152 | game, 153 | playerData, 154 | objectReady, 155 | ); 156 | if (location !== undefined) { 157 | logger( 158 | `Completed: ${queueTypeToName(queueType)}: ${objectReady.name}, placing at ${location.rx},${ 159 | location.ry 160 | }`, 161 | ); 162 | actionsApi.placeBuilding(objectReady.name, location.rx, location.ry); 163 | } else { 164 | logger(`Completed: ${queueTypeToName(queueType)}: ${objectReady.name} but nowhere to place it`); 165 | } 166 | } 167 | } else if (queueData.status == QueueStatus.Active && queueData.items.length > 0 && decision != null) { 168 | // Consider cancelling if something else is significantly higher priority than what is currently being produced. 169 | const currentProduction = queueData.items[0].rules; 170 | if (decision.unit != currentProduction) { 171 | // Changing our mind. 172 | let currentItemPriority = this.getPriorityForBuildingOption( 173 | currentProduction, 174 | game, 175 | playerData, 176 | threatCache, 177 | ); 178 | let newItemPriority = decision.priority; 179 | if (newItemPriority > currentItemPriority * 2) { 180 | logger( 181 | `Dequeueing queue ${queueTypeToName(queueData.type)} unit ${currentProduction.name} because ${ 182 | decision.unit.name 183 | } has 2x higher priority.`, 184 | ); 185 | actionsApi.unqueueFromProduction(queueData.type, currentProduction.name, currentProduction.type, 1); 186 | } 187 | } else { 188 | // Not changing our mind, but maybe other queues are more important for now. 189 | if (totalCostAcrossQueues > myCredits && decision.priority < totalWeightAcrossQueues * 0.25) { 190 | logger( 191 | `Pausing queue ${queueTypeToName(queueData.type)} because weight is low (${ 192 | decision.priority 193 | }/${totalWeightAcrossQueues})`, 194 | ); 195 | actionsApi.pauseProduction(queueData.type); 196 | } 197 | } 198 | } else if (queueData.status == QueueStatus.OnHold) { 199 | // Consider resuming queue if priority is high relative to other queues. 200 | if (myCredits >= totalCostAcrossQueues) { 201 | logger(`Resuming queue ${queueTypeToName(queueData.type)} because credits are high`); 202 | actionsApi.resumeProduction(queueData.type); 203 | } else if (decision && decision.priority >= totalWeightAcrossQueues * 0.25) { 204 | logger( 205 | `Resuming queue ${queueTypeToName(queueData.type)} because weight is high (${ 206 | decision.priority 207 | }/${totalWeightAcrossQueues})`, 208 | ); 209 | actionsApi.resumeProduction(queueData.type); 210 | } 211 | } 212 | } 213 | 214 | private getPrioritiesForBuildingOptions( 215 | game: GameApi, 216 | options: TechnoRules[], 217 | threatCache: GlobalThreat | null, 218 | playerData: PlayerData, 219 | unitTypeRequests: Map, 220 | logger: DebugLogger, 221 | ): TechnoRulesWithPriority[] { 222 | let priorityQueue: TechnoRulesWithPriority[] = []; 223 | options.forEach((option) => { 224 | const calculatedPriority = this.getPriorityForBuildingOption(option, game, playerData, threatCache); 225 | // Get the higher of the dynamic and the mission priority for the unit. 226 | const actualPriority = Math.max( 227 | calculatedPriority, 228 | unitTypeRequests.get(option.name) ?? calculatedPriority, 229 | ); 230 | if (actualPriority > 0) { 231 | priorityQueue.push({ unit: option, priority: actualPriority }); 232 | } 233 | }); 234 | 235 | priorityQueue = priorityQueue.sort((a, b) => a.priority - b.priority); 236 | return priorityQueue; 237 | } 238 | 239 | private getPriorityForBuildingOption( 240 | option: TechnoRules, 241 | game: GameApi, 242 | playerStatus: PlayerData, 243 | threatCache: GlobalThreat | null, 244 | ) { 245 | if (BUILDING_NAME_TO_RULES.has(option.name)) { 246 | let logic = BUILDING_NAME_TO_RULES.get(option.name)!; 247 | return logic.getPriority(game, playerStatus, option, threatCache); 248 | } else { 249 | // Fallback priority when there are no rules. 250 | return ( 251 | DEFAULT_BUILDING_PRIORITY - game.getVisibleUnits(playerStatus.name, "self", (r) => r == option).length 252 | ); 253 | } 254 | } 255 | 256 | private getBestLocationForStructure( 257 | game: GameApi, 258 | playerData: PlayerData, 259 | objectReady: TechnoRules, 260 | ): { rx: number; ry: number } | undefined { 261 | if (BUILDING_NAME_TO_RULES.has(objectReady.name)) { 262 | let logic = BUILDING_NAME_TO_RULES.get(objectReady.name)!; 263 | return logic.getPlacementLocation(game, playerData, objectReady); 264 | } else { 265 | // fallback placement logic 266 | return getDefaultPlacementLocation(game, playerData, playerData.startLocation, objectReady); 267 | } 268 | } 269 | 270 | public getGlobalDebugText(gameApi: GameApi, productionApi: ProductionApi) { 271 | const productionState = QUEUES.reduce((prev, queueType) => { 272 | if (productionApi.getQueueData(queueType).size === 0) { 273 | return prev; 274 | } 275 | const paused = productionApi.getQueueData(queueType).status === QueueStatus.OnHold; 276 | return ( 277 | prev + 278 | " [" + 279 | queueTypeToName(queueType) + 280 | (paused ? " PAUSED" : "") + 281 | ": " + 282 | productionApi 283 | .getQueueData(queueType) 284 | .items.map((item) => item.rules.name + (item.quantity > 1 ? "x" + item.quantity : "")) + 285 | "]" 286 | ); 287 | }, ""); 288 | 289 | const queueStates = this.queueStates 290 | .filter((queueState) => queueState.items.length > 0) 291 | .map((queueState) => { 292 | const queueString = queueState.items 293 | .map((item) => item.unit.name + "(" + Math.round(item.priority * 10) / 10 + ")") 294 | .join(", "); 295 | return `${queueTypeToName(queueState.queue)} Prios: ${queueString}\n`; 296 | }) 297 | .join(""); 298 | 299 | return `Production: ${productionState}\n${queueStates}`; 300 | } 301 | } 302 | -------------------------------------------------------------------------------- /src/bot/logic/building/resourceCollectionBuilding.ts: -------------------------------------------------------------------------------- 1 | import { GameApi, GameMath, PlayerData, TechnoRules, Tile } from "@chronodivide/game-api"; 2 | import { GlobalThreat } from "../threat/threat.js"; 3 | import { BasicBuilding } from "./basicBuilding.js"; 4 | import { getDefaultPlacementLocation } from "./buildingRules.js"; 5 | import { Vector2 } from "three"; 6 | 7 | export class ResourceCollectionBuilding extends BasicBuilding { 8 | constructor(basePriority: number, maxNeeded: number, onlyBuildWhenFloatingCreditsAmount?: number) { 9 | super(basePriority, maxNeeded, onlyBuildWhenFloatingCreditsAmount); 10 | } 11 | 12 | getPlacementLocation( 13 | game: GameApi, 14 | playerData: PlayerData, 15 | technoRules: TechnoRules, 16 | ): { rx: number; ry: number } | undefined { 17 | // Prefer spawning close to ore. 18 | let selectedLocation = playerData.startLocation; 19 | 20 | var closeOre: Tile | undefined; 21 | var closeOreDist: number | undefined; 22 | let allTileResourceData = game.mapApi.getAllTilesResourceData(); 23 | for (let i = 0; i < allTileResourceData.length; ++i) { 24 | let tileResourceData = allTileResourceData[i]; 25 | if (tileResourceData.spawnsOre) { 26 | let dist = GameMath.sqrt( 27 | (selectedLocation.x - tileResourceData.tile.rx) ** 2 + 28 | (selectedLocation.y - tileResourceData.tile.ry) ** 2, 29 | ); 30 | if (closeOreDist == undefined || dist < closeOreDist) { 31 | closeOreDist = dist; 32 | closeOre = tileResourceData.tile; 33 | } 34 | } 35 | } 36 | if (closeOre) { 37 | selectedLocation = new Vector2(closeOre.rx, closeOre.ry); 38 | } 39 | return getDefaultPlacementLocation(game, playerData, selectedLocation, technoRules); 40 | } 41 | 42 | // Don't build/start selling these if we don't have any harvesters 43 | getMaxCount( 44 | game: GameApi, 45 | playerData: PlayerData, 46 | technoRules: TechnoRules, 47 | threatCache: GlobalThreat | null, 48 | ): number | null { 49 | const harvesters = game.getVisibleUnits(playerData.name, "self", (r) => r.harvester).length; 50 | return Math.max(1, harvesters * 2); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/bot/logic/common/rulesCache.ts: -------------------------------------------------------------------------------- 1 | import { GameApi, TechnoRules } from "@chronodivide/game-api"; 2 | 3 | // checking technorules directly reduces the amount of calls to getUnitData(), which is a relatively expensive function. 4 | // A null value indicates an object that does not have TechnoRules. 5 | const technoRulesCache: { [rulesName: string]: TechnoRules | null } = {}; 6 | 7 | export const getCachedTechnoRules = (gameApi: GameApi, unitId: number): TechnoRules | null => { 8 | const gameObject = gameApi.getGameObjectData(unitId); 9 | if (!gameObject) { 10 | return null; 11 | } 12 | const { rulesApi } = gameApi; 13 | const { name } = gameObject; 14 | 15 | if (technoRulesCache[name]) { 16 | // object is present in cache, either with TechnoRules or null (indicating that it does not have TechnoRules) 17 | return technoRulesCache[name]; 18 | } 19 | 20 | const aircraftRules = rulesApi.aircraftRules.get(name); 21 | if (aircraftRules) { 22 | technoRulesCache[name] = aircraftRules; 23 | return aircraftRules; 24 | } 25 | 26 | const buildingRules = rulesApi.buildingRules.get(name); 27 | if (buildingRules) { 28 | technoRulesCache[name] = buildingRules; 29 | return buildingRules; 30 | } 31 | 32 | const infantryRules = rulesApi.infantryRules.get(name); 33 | if (infantryRules) { 34 | technoRulesCache[name] = infantryRules; 35 | return infantryRules; 36 | } 37 | 38 | const vehicleRules = rulesApi.vehicleRules.get(name); 39 | if (vehicleRules) { 40 | technoRulesCache[name] = vehicleRules; 41 | return vehicleRules; 42 | } 43 | 44 | technoRulesCache[name] = null; 45 | return null; 46 | }; 47 | -------------------------------------------------------------------------------- /src/bot/logic/common/scout.ts: -------------------------------------------------------------------------------- 1 | import { GameApi, GameMath, PlayerData, Vector2 } from "@chronodivide/game-api"; 2 | import { Sector, SectorCache } from "../map/sector"; 3 | import { DebugLogger } from "./utils"; 4 | import { PriorityQueue } from "@datastructures-js/priority-queue"; 5 | 6 | export const getUnseenStartingLocations = (gameApi: GameApi, playerData: PlayerData) => { 7 | const unseenStartingLocations = gameApi.mapApi.getStartingLocations().filter((startingLocation) => { 8 | if (startingLocation == playerData.startLocation) { 9 | return false; 10 | } 11 | let tile = gameApi.mapApi.getTile(startingLocation.x, startingLocation.y); 12 | return tile ? !gameApi.mapApi.isVisibleTile(tile, playerData.name) : false; 13 | }); 14 | return unseenStartingLocations; 15 | }; 16 | 17 | export class PrioritisedScoutTarget { 18 | private _targetPoint?: Vector2; 19 | private _targetSector?: Sector; 20 | private _priority: number; 21 | 22 | constructor( 23 | priority: number, 24 | target: Vector2 | Sector, 25 | private permanent: boolean = false, 26 | ) { 27 | if (target.hasOwnProperty("x") && target.hasOwnProperty("y")) { 28 | this._targetPoint = target as Vector2; 29 | } else if (target.hasOwnProperty("sectorStartPoint")) { 30 | this._targetSector = target as Sector; 31 | } else { 32 | throw new TypeError(`invalid object passed as target: ${target}`); 33 | } 34 | this._priority = priority; 35 | } 36 | 37 | get priority() { 38 | return this._priority; 39 | } 40 | 41 | asVector2() { 42 | return this._targetPoint ?? this._targetSector?.sectorStartPoint ?? null; 43 | } 44 | 45 | get targetSector() { 46 | return this._targetSector; 47 | } 48 | 49 | get isPermanent() { 50 | return this.permanent; 51 | } 52 | } 53 | 54 | const ENEMY_SPAWN_POINT_PRIORITY = 100; 55 | 56 | // Amount of sectors around the starting sector to try to scout. 57 | const NEARBY_SECTOR_STARTING_RADIUS = 2; 58 | const NEARBY_SECTOR_BASE_PRIORITY = 1000; 59 | 60 | // Amount of ticks per 'radius' to expand for scouting. 61 | const SCOUTING_RADIUS_EXPANSION_TICKS = 9000; // 10 minutes 62 | 63 | export class ScoutingManager { 64 | private scoutingQueue: PriorityQueue; 65 | 66 | private queuedRadius = NEARBY_SECTOR_STARTING_RADIUS; 67 | 68 | constructor(private logger: DebugLogger) { 69 | // Order by descending priority. 70 | this.scoutingQueue = new PriorityQueue( 71 | (a: PrioritisedScoutTarget, b: PrioritisedScoutTarget) => b.priority - a.priority, 72 | ); 73 | } 74 | 75 | addRadiusToScout( 76 | gameApi: GameApi, 77 | centerPoint: Vector2, 78 | sectorCache: SectorCache, 79 | radius: number, 80 | startingPriority: number, 81 | ) { 82 | const { x: startX, y: startY } = centerPoint; 83 | const { width: sectorsX, height: sectorsY } = sectorCache.getSectorBounds(); 84 | const startingSector = sectorCache.getSectorCoordinatesForWorldPosition(startX, startY); 85 | 86 | if (!startingSector) { 87 | return; 88 | } 89 | 90 | for ( 91 | let x: number = Math.max(0, startingSector.sectorX - radius); 92 | x < Math.min(sectorsX, startingSector.sectorX + radius); 93 | ++x 94 | ) { 95 | for ( 96 | let y: number = Math.max(0, startingSector.sectorY - radius); 97 | y < Math.min(sectorsY, startingSector.sectorY + radius); 98 | ++y 99 | ) { 100 | if (x === startingSector?.sectorX && y === startingSector?.sectorY) { 101 | continue; 102 | } 103 | // Make it scout closer sectors first. 104 | const distanceFactor = 105 | GameMath.pow(x - startingSector.sectorX, 2) + GameMath.pow(y - startingSector.sectorY, 2); 106 | const sector = sectorCache.getSector(x, y); 107 | if (sector) { 108 | const maybeTarget = new PrioritisedScoutTarget(startingPriority - distanceFactor, sector); 109 | const maybePoint = maybeTarget.asVector2(); 110 | if (maybePoint && gameApi.mapApi.getTile(maybePoint.x, maybePoint.y)) { 111 | this.scoutingQueue.enqueue(maybeTarget); 112 | } 113 | } 114 | } 115 | } 116 | } 117 | 118 | onGameStart(gameApi: GameApi, playerData: PlayerData, sectorCache: SectorCache) { 119 | // Queue hostile starting locations with high priority and as permanent scouting candidates. 120 | gameApi.mapApi 121 | .getStartingLocations() 122 | .filter((startingLocation) => { 123 | if (startingLocation == playerData.startLocation) { 124 | return false; 125 | } 126 | let tile = gameApi.mapApi.getTile(startingLocation.x, startingLocation.y); 127 | return tile ? !gameApi.mapApi.isVisibleTile(tile, playerData.name) : false; 128 | }) 129 | .map((tile) => new PrioritisedScoutTarget(ENEMY_SPAWN_POINT_PRIORITY, tile, true)) 130 | .forEach((target) => { 131 | this.logger(`Adding ${target.asVector2()?.x},${target.asVector2()?.y} to initial scouting queue`); 132 | this.scoutingQueue.enqueue(target); 133 | }); 134 | 135 | // Queue sectors near the spawn point. 136 | this.addRadiusToScout( 137 | gameApi, 138 | playerData.startLocation, 139 | sectorCache, 140 | NEARBY_SECTOR_STARTING_RADIUS, 141 | NEARBY_SECTOR_BASE_PRIORITY, 142 | ); 143 | } 144 | 145 | onAiUpdate(gameApi: GameApi, playerData: PlayerData, sectorCache: SectorCache) { 146 | const currentHead = this.scoutingQueue.front(); 147 | if (!currentHead) { 148 | return; 149 | } 150 | const head = currentHead.asVector2(); 151 | if (!head) { 152 | this.scoutingQueue.dequeue(); 153 | return; 154 | } 155 | const { x, y } = head; 156 | const tile = gameApi.mapApi.getTile(x, y); 157 | if (tile && gameApi.mapApi.isVisibleTile(tile, playerData.name)) { 158 | this.logger(`head point is visible, dequeueing`); 159 | this.scoutingQueue.dequeue(); 160 | } 161 | 162 | const requiredRadius = Math.floor(gameApi.getCurrentTick() / SCOUTING_RADIUS_EXPANSION_TICKS); 163 | if (requiredRadius > this.queuedRadius) { 164 | this.logger(`expanding scouting radius from ${this.queuedRadius} to ${requiredRadius}`); 165 | this.addRadiusToScout( 166 | gameApi, 167 | playerData.startLocation, 168 | sectorCache, 169 | requiredRadius, 170 | NEARBY_SECTOR_BASE_PRIORITY, 171 | ); 172 | this.queuedRadius = requiredRadius; 173 | } 174 | } 175 | 176 | getNewScoutTarget() { 177 | return this.scoutingQueue.dequeue(); 178 | } 179 | 180 | hasScoutTargets() { 181 | return !this.scoutingQueue.isEmpty(); 182 | } 183 | } 184 | -------------------------------------------------------------------------------- /src/bot/logic/common/utils.ts: -------------------------------------------------------------------------------- 1 | import { GameObjectData, TechnoRules, UnitData } from "@chronodivide/game-api"; 2 | 3 | export enum Countries { 4 | USA = "Americans", 5 | KOREA = "Alliance", 6 | FRANCE = "French", 7 | GERMANY = "Germans", 8 | GREAT_BRITAIN = "British", 9 | LIBYA = "Africans", 10 | IRAQ = "Arabs", 11 | CUBA = "Confederation", 12 | RUSSIA = "Russians", 13 | } 14 | 15 | export type DebugLogger = (message: string, sayInGame?: boolean) => void; 16 | 17 | export const isOwnedByNeutral = (unitData: UnitData | undefined) => unitData?.owner === "@@NEUTRAL@@"; 18 | 19 | // Return if the given unit would have .isSelectableCombatant = true. 20 | // Usable on GameObjectData (which is faster to get than TechnoRules) 21 | export const isSelectableCombatant = (rules: GameObjectData | undefined) => 22 | !!(rules?.rules as any)?.isSelectableCombatant; 23 | 24 | // Thanks use-strict! 25 | export function formatTimeDuration(timeSeconds: number, skipZeroHours = false) { 26 | let h = Math.floor(timeSeconds / 3600); 27 | timeSeconds -= h * 3600; 28 | let m = Math.floor(timeSeconds / 60); 29 | timeSeconds -= m * 60; 30 | let s = Math.floor(timeSeconds); 31 | 32 | return [...(h || !skipZeroHours ? [h] : []), pad(m, "00"), pad(s, "00")].join(":"); 33 | } 34 | 35 | export function pad(n: any, format = "0000") { 36 | let str = "" + n; 37 | return format.substring(0, format.length - str.length) + str; 38 | } 39 | 40 | // So we don't need lodash 41 | export function minBy(array: T[], predicate: (arg: T) => number | null): T | null { 42 | if (array.length === 0) { 43 | return null; 44 | } 45 | let minIdx = 0; 46 | let minVal = predicate(array[0]); 47 | for (let i = 1; i < array.length; ++i) { 48 | const newVal = predicate(array[i]); 49 | if (minVal === null || (newVal !== null && newVal < minVal)) { 50 | minIdx = i; 51 | minVal = newVal; 52 | } 53 | } 54 | return minVal !== null ? array[minIdx] : null; 55 | } 56 | 57 | export function maxBy(array: T[], predicate: (arg: T) => number | null): T | null { 58 | if (array.length === 0) { 59 | return null; 60 | } 61 | let maxIdx = 0; 62 | let maxVal = predicate(array[0]); 63 | for (let i = 1; i < array.length; ++i) { 64 | const newVal = predicate(array[i]); 65 | if (maxVal === null || (newVal !== null && newVal > maxVal)) { 66 | maxIdx = i; 67 | maxVal = newVal; 68 | } 69 | } 70 | return maxVal !== null ? array[maxIdx] : null; 71 | } 72 | 73 | export function uniqBy(array: T[], predicate: (arg: T) => string | number): T[] { 74 | return Object.values( 75 | array.reduce( 76 | (prev, newVal) => { 77 | const val = predicate(newVal); 78 | if (!prev[val]) { 79 | prev[val] = newVal; 80 | } 81 | return prev; 82 | }, 83 | {} as Record, 84 | ), 85 | ); 86 | } 87 | 88 | export function countBy(array: T[], predicate: (arg: T) => string | undefined): { [key: string]: number } { 89 | return array.reduce( 90 | (prev, newVal) => { 91 | const val = predicate(newVal); 92 | if (val === undefined) { 93 | return prev; 94 | } 95 | if (!prev[val]) { 96 | prev[val] = 0; 97 | } 98 | prev[val] = prev[val] + 1; 99 | return prev; 100 | }, 101 | {} as Record, 102 | ); 103 | } 104 | 105 | export function groupBy(array: V[], predicate: (arg: V) => K): { [key in K]: V[] } { 106 | return array.reduce( 107 | (prev, newVal) => { 108 | const val = predicate(newVal); 109 | if (val === undefined) { 110 | return prev; 111 | } 112 | if (!prev.hasOwnProperty(val)) { 113 | prev[val] = []; 114 | } 115 | prev[val].push(newVal); 116 | return prev; 117 | }, 118 | {} as Record, 119 | ); 120 | } 121 | 122 | export function setDifference(a: Set, b: Set): T[] { 123 | const map = new Map(); 124 | a.forEach((v) => map.set(v, 1)); 125 | b.forEach((v) => map.set(v, (map.get(v) ?? 0) + 1)); 126 | return [...map.entries()].filter(([key, val]) => val !== 2).map(([key]) => key); 127 | } 128 | -------------------------------------------------------------------------------- /src/bot/logic/composition/alliedCompositions.ts: -------------------------------------------------------------------------------- 1 | import { GameApi, PlayerData } from "@chronodivide/game-api"; 2 | import { MatchAwareness } from "../awareness"; 3 | import { UnitComposition } from "./common"; 4 | 5 | export const getAlliedCompositions = ( 6 | gameApi: GameApi, 7 | playerData: PlayerData, 8 | matchAwareness: MatchAwareness, 9 | ): UnitComposition => { 10 | const hasWarFactory = gameApi.getVisibleUnits(playerData.name, "self", (r) => r.name === "GAWEAP").length > 0; 11 | const hasAirforce = 12 | gameApi.getVisibleUnits(playerData.name, "self", (r) => r.name === "GAAIRC" || r.name === "AMRADR").length > 0; 13 | const hasBattleLab = gameApi.getVisibleUnits(playerData.name, "self", (r) => r.name === "GATECH").length > 0; 14 | 15 | const includeInfantry = !hasAirforce && !hasBattleLab; 16 | return { 17 | ...(includeInfantry && { E1: 5 }), 18 | ...(hasWarFactory && { MTNK: 3, FV: 2 }), 19 | ...(hasAirforce && { JUMPJET: 6 }), 20 | ...(hasBattleLab && { SREF: 2, MGTK: 3 }), 21 | }; 22 | }; 23 | -------------------------------------------------------------------------------- /src/bot/logic/composition/common.ts: -------------------------------------------------------------------------------- 1 | export type UnitComposition = { 2 | [unitType: string]: number; 3 | }; 4 | -------------------------------------------------------------------------------- /src/bot/logic/composition/sovietCompositions.ts: -------------------------------------------------------------------------------- 1 | import { GameApi, PlayerData } from "@chronodivide/game-api"; 2 | import { MatchAwareness } from "../awareness"; 3 | import { UnitComposition } from "./common"; 4 | 5 | export const getSovietComposition = ( 6 | gameApi: GameApi, 7 | playerData: PlayerData, 8 | matchAwareness: MatchAwareness, 9 | ): UnitComposition => { 10 | const hasWarFactory = gameApi.getVisibleUnits(playerData.name, "self", (r) => r.name === "NAWEAP").length > 0; 11 | const hasRadar = gameApi.getVisibleUnits(playerData.name, "self", (r) => r.name === "NARADR").length > 0; 12 | const hasBattleLab = gameApi.getVisibleUnits(playerData.name, "self", (r) => r.name === "NATECH").length > 0; 13 | 14 | const includeInfantry = !hasBattleLab; 15 | return { 16 | ...(includeInfantry && { E2: 10 }), 17 | ...(hasWarFactory && { HTNK: 3, HTK: 2 }), 18 | ...(hasRadar && { V3: 1 }), 19 | ...(hasBattleLab && { APOC: 2 }), 20 | }; 21 | }; 22 | -------------------------------------------------------------------------------- /src/bot/logic/map/map.ts: -------------------------------------------------------------------------------- 1 | import { GameApi, GameMath, MapApi, PlayerData, Size, Tile, UnitData, Vector2 } from "@chronodivide/game-api"; 2 | import { maxBy } from "../common/utils.js"; 3 | 4 | export function determineMapBounds(mapApi: MapApi): Size { 5 | return mapApi.getRealMapSize(); 6 | } 7 | 8 | export function calculateAreaVisibility( 9 | mapApi: MapApi, 10 | playerData: PlayerData, 11 | startPoint: Vector2, 12 | endPoint: Vector2, 13 | ): { visibleTiles: number; validTiles: number } { 14 | let validTiles: number = 0, 15 | visibleTiles: number = 0; 16 | for (let xx = startPoint.x; xx < endPoint.x; ++xx) { 17 | for (let yy = startPoint.y; yy < endPoint.y; ++yy) { 18 | let tile = mapApi.getTile(xx, yy); 19 | if (tile) { 20 | ++validTiles; 21 | if (mapApi.isVisibleTile(tile, playerData.name)) { 22 | ++visibleTiles; 23 | } 24 | } 25 | } 26 | } 27 | let result = { visibleTiles, validTiles }; 28 | return result; 29 | } 30 | 31 | export function getPointTowardsOtherPoint( 32 | gameApi: GameApi, 33 | startLocation: Vector2, 34 | endLocation: Vector2, 35 | minRadius: number, 36 | maxRadius: number, 37 | randomAngle: number, 38 | ): Vector2 { 39 | // TODO: Use proper vector maths here. 40 | let radius = minRadius + Math.round(gameApi.generateRandom() * (maxRadius - minRadius)); 41 | let directionToEndLocation = GameMath.atan2(endLocation.y - startLocation.y, endLocation.x - startLocation.x); 42 | let randomisedDirection = 43 | directionToEndLocation - 44 | (randomAngle * (Math.PI / 12) + 2 * randomAngle * gameApi.generateRandom() * (Math.PI / 12)); 45 | let candidatePointX = Math.round(startLocation.x + GameMath.cos(randomisedDirection) * radius); 46 | let candidatePointY = Math.round(startLocation.y + GameMath.sin(randomisedDirection) * radius); 47 | return new Vector2(candidatePointX, candidatePointY); 48 | } 49 | 50 | export function getDistanceBetweenPoints(startLocation: Vector2, endLocation: Vector2): number { 51 | // TODO: Remove this now we have Vector2s. 52 | return startLocation.distanceTo(endLocation); 53 | } 54 | 55 | export function getDistanceBetweenTileAndPoint(tile: Tile, vector: Vector2): number { 56 | // TODO: Remove this now we have Vector2s. 57 | return new Vector2(tile.rx, tile.ry).distanceTo(vector); 58 | } 59 | 60 | export function getDistanceBetweenUnits(unit1: UnitData, unit2: UnitData): number { 61 | return new Vector2(unit1.tile.rx, unit1.tile.ry).distanceTo(new Vector2(unit2.tile.rx, unit2.tile.ry)); 62 | } 63 | 64 | export function getDistanceBetween(unit: UnitData, point: Vector2): number { 65 | return getDistanceBetweenPoints(new Vector2(unit.tile.rx, unit.tile.ry), point); 66 | } 67 | -------------------------------------------------------------------------------- /src/bot/logic/map/sector.ts: -------------------------------------------------------------------------------- 1 | // A sector is a uniform-sized segment of the map. 2 | 3 | import { MapApi, PlayerData, Size, Tile, Vector2 } from "@chronodivide/game-api"; 4 | import { calculateAreaVisibility } from "./map.js"; 5 | 6 | export const SECTOR_SIZE = 8; 7 | 8 | export class Sector { 9 | // How many times we've attempted to enter the sector. 10 | private sectorExploreAttempts: number; 11 | private sectorLastExploredAt: number | undefined; 12 | 13 | constructor( 14 | public sectorStartPoint: Vector2, 15 | public sectorStartTile: Tile | undefined, 16 | public sectorVisibilityPct: number | undefined, 17 | public sectorVisibilityLastCheckTick: number | undefined, 18 | ) { 19 | this.sectorExploreAttempts = 0; 20 | } 21 | 22 | public onExploreAttempted(currentTick: number) { 23 | this.sectorExploreAttempts++; 24 | this.sectorLastExploredAt = currentTick; 25 | } 26 | 27 | // Whether we should attempt to explore this sector, given the cooldown and limit of attempts. 28 | public shouldAttemptExploration(currentTick: number, cooldown: number, limit: number) { 29 | if (limit >= this.sectorExploreAttempts) { 30 | return false; 31 | } 32 | 33 | if (this.sectorLastExploredAt && currentTick < this.sectorLastExploredAt + cooldown) { 34 | return false; 35 | } 36 | 37 | return true; 38 | } 39 | } 40 | 41 | export class SectorCache { 42 | private sectors: Sector[][] = []; 43 | private mapBounds: Size; 44 | private sectorsX: number; 45 | private sectorsY: number; 46 | private lastUpdatedSectorX: number | undefined; 47 | private lastUpdatedSectorY: number | undefined; 48 | 49 | constructor(mapApi: MapApi, mapBounds: Size) { 50 | this.mapBounds = mapBounds; 51 | this.sectorsX = Math.ceil(mapBounds.width / SECTOR_SIZE); 52 | this.sectorsY = Math.ceil(mapBounds.height / SECTOR_SIZE); 53 | this.sectors = new Array(this.sectorsX); 54 | for (let xx = 0; xx < this.sectorsX; ++xx) { 55 | this.sectors[xx] = new Array(this.sectorsY); 56 | for (let yy = 0; yy < this.sectorsY; ++yy) { 57 | const tileX = xx * SECTOR_SIZE; 58 | const tileY = yy * SECTOR_SIZE; 59 | this.sectors[xx][yy] = new Sector( 60 | new Vector2(tileX, tileY), 61 | mapApi.getTile(tileX, tileY), 62 | undefined, 63 | undefined, 64 | ); 65 | } 66 | } 67 | } 68 | 69 | public getMapBounds(): Size { 70 | return this.mapBounds; 71 | } 72 | 73 | public updateSectors(currentGameTick: number, maxSectorsToUpdate: number, mapApi: MapApi, playerData: PlayerData) { 74 | let nextSectorX = this.lastUpdatedSectorX ? this.lastUpdatedSectorX + 1 : 0; 75 | let nextSectorY = this.lastUpdatedSectorY ? this.lastUpdatedSectorY : 0; 76 | let updatedThisCycle = 0; 77 | 78 | while (updatedThisCycle < maxSectorsToUpdate) { 79 | if (nextSectorX >= this.sectorsX) { 80 | nextSectorX = 0; 81 | ++nextSectorY; 82 | } 83 | if (nextSectorY >= this.sectorsY) { 84 | nextSectorY = 0; 85 | nextSectorX = 0; 86 | } 87 | let sector: Sector | undefined = this.getSector(nextSectorX, nextSectorY); 88 | if (sector) { 89 | sector.sectorVisibilityLastCheckTick = currentGameTick; 90 | let sp = sector.sectorStartPoint; 91 | let ep = new Vector2(sp.x + SECTOR_SIZE, sp.y + SECTOR_SIZE); 92 | let visibility = calculateAreaVisibility(mapApi, playerData, sp, ep); 93 | if (visibility.validTiles > 0) { 94 | sector.sectorVisibilityPct = visibility.visibleTiles / visibility.validTiles; 95 | } else { 96 | sector.sectorVisibilityPct = undefined; 97 | } 98 | } 99 | this.lastUpdatedSectorX = nextSectorX; 100 | this.lastUpdatedSectorY = nextSectorY; 101 | ++nextSectorX; 102 | ++updatedThisCycle; 103 | } 104 | } 105 | 106 | // Return % of sectors that are updated. 107 | public getSectorUpdateRatio(sectorsUpdatedSinceGameTick: number): number { 108 | let updated = 0, 109 | total = 0; 110 | for (let xx = 0; xx < this.sectorsX; ++xx) { 111 | for (let yy = 0; yy < this.sectorsY; ++yy) { 112 | let sector: Sector = this.sectors[xx][yy]; 113 | if ( 114 | sector && 115 | sector.sectorVisibilityLastCheckTick && 116 | sector.sectorVisibilityLastCheckTick >= sectorsUpdatedSinceGameTick 117 | ) { 118 | ++updated; 119 | } 120 | ++total; 121 | } 122 | } 123 | return updated / total; 124 | } 125 | 126 | /** 127 | * Return the ratio (0-1) of tiles that are visible. Returns undefined if we haven't scanned the whole map yet. 128 | */ 129 | public getOverallVisibility(): number | undefined { 130 | let visible = 0, 131 | total = 0; 132 | for (let xx = 0; xx < this.sectorsX; ++xx) { 133 | for (let yy = 0; yy < this.sectorsY; ++yy) { 134 | let sector: Sector = this.sectors[xx][yy]; 135 | 136 | // Undefined visibility. 137 | if (sector.sectorVisibilityPct != undefined) { 138 | visible += sector.sectorVisibilityPct; 139 | total += 1.0; 140 | } 141 | } 142 | } 143 | return visible / total; 144 | } 145 | 146 | public getSector(sectorX: number, sectorY: number): Sector | undefined { 147 | if (sectorX < 0 || sectorX >= this.sectorsX || sectorY < 0 || sectorY >= this.sectorsY) { 148 | return undefined; 149 | } 150 | return this.sectors[sectorX][sectorY]; 151 | } 152 | 153 | public getSectorBounds(): Size { 154 | return { width: this.sectorsX, height: this.sectorsY }; 155 | } 156 | 157 | public getSectorCoordinatesForWorldPosition(x: number, y: number) { 158 | if (x < 0 || x >= this.mapBounds.width || y < 0 || y >= this.mapBounds.height) { 159 | return undefined; 160 | } 161 | return { 162 | sectorX: Math.floor(x / SECTOR_SIZE), 163 | sectorY: Math.floor(y / SECTOR_SIZE), 164 | }; 165 | } 166 | 167 | public getSectorForWorldPosition(x: number, y: number): Sector | undefined { 168 | const sectorCoordinates = this.getSectorCoordinatesForWorldPosition(x, y); 169 | if (!sectorCoordinates) { 170 | return undefined; 171 | } 172 | return this.sectors[Math.floor(x / SECTOR_SIZE)][Math.floor(y / SECTOR_SIZE)]; 173 | } 174 | } 175 | -------------------------------------------------------------------------------- /src/bot/logic/mission/actionBatcher.ts: -------------------------------------------------------------------------------- 1 | // Used to group related actions together to minimise actionApi calls. For example, if multiple units 2 | 3 | import { ActionsApi, OrderType, Vector2 } from "@chronodivide/game-api"; 4 | import { groupBy } from "../common/utils.js"; 5 | 6 | // are ordered to move to the same location, all of them will be ordered to move in a single action. 7 | export class BatchableAction { 8 | private constructor( 9 | private _unitId: number, 10 | private _orderType: OrderType, 11 | private _point?: Vector2, 12 | private _targetId?: number, 13 | // If you don't want this action to be swallowed by dedupe, provide a unique nonce 14 | private _nonce: number = 0, 15 | ) {} 16 | 17 | static noTarget(unitId: number, orderType: OrderType, nonce: number = 0) { 18 | return new BatchableAction(unitId, orderType, undefined, undefined, nonce); 19 | } 20 | 21 | static toPoint(unitId: number, orderType: OrderType, point: Vector2, nonce: number = 0) { 22 | return new BatchableAction(unitId, orderType, point, undefined); 23 | } 24 | 25 | static toTargetId(unitId: number, orderType: OrderType, targetId: number, nonce: number = 0) { 26 | return new BatchableAction(unitId, orderType, undefined, targetId, nonce); 27 | } 28 | 29 | public get unitId() { 30 | return this._unitId; 31 | } 32 | 33 | public get orderType() { 34 | return this._orderType; 35 | } 36 | 37 | public get point() { 38 | return this._point; 39 | } 40 | 41 | public get targetId() { 42 | return this._targetId; 43 | } 44 | 45 | public isSameAs(other: BatchableAction) { 46 | if (this._unitId !== other._unitId) { 47 | return false; 48 | } 49 | if (this._orderType !== other._orderType) { 50 | return false; 51 | } 52 | if (this._point !== other._point) { 53 | return false; 54 | } 55 | if (this._targetId !== other._targetId) { 56 | return false; 57 | } 58 | if (this._nonce !== other._nonce) { 59 | return false; 60 | } 61 | return true; 62 | } 63 | } 64 | 65 | export class ActionBatcher { 66 | private actions: BatchableAction[]; 67 | 68 | constructor() { 69 | this.actions = []; 70 | } 71 | 72 | push(action: BatchableAction) { 73 | this.actions.push(action); 74 | } 75 | 76 | resolve(actionsApi: ActionsApi) { 77 | const groupedCommands = groupBy(this.actions, (action) => action.orderType.valueOf().toString()); 78 | const vectorToStr = (v: Vector2) => v.x + "," + v.y; 79 | const strToVector = (str: string) => { 80 | const [x, y] = str.split(","); 81 | return new Vector2(parseInt(x), parseInt(y)); 82 | }; 83 | 84 | // Group by command type. 85 | Object.entries(groupedCommands).forEach(([commandValue, commands]) => { 86 | // i hate this 87 | const commandType: OrderType = parseInt(commandValue) as OrderType; 88 | // Group by command target ID. 89 | const byTarget = groupBy( 90 | commands.filter((command) => !!command.targetId), 91 | (command) => command.targetId?.toString()!, 92 | ); 93 | Object.entries(byTarget).forEach(([targetId, unitCommands]) => { 94 | actionsApi.orderUnits( 95 | unitCommands.map((command) => command.unitId), 96 | commandType, 97 | parseInt(targetId), 98 | ); 99 | }); 100 | // Group by position (the vector is encoded as a string of the form "x,y") 101 | const byPosition = groupBy( 102 | commands.filter((command) => !!command.point), 103 | (command) => vectorToStr(command.point!), 104 | ); 105 | Object.entries(byPosition).forEach(([point, unitCommands]) => { 106 | const vector = strToVector(point); 107 | actionsApi.orderUnits( 108 | unitCommands.map((command) => command.unitId), 109 | commandType, 110 | vector.x, 111 | vector.y, 112 | ); 113 | }); 114 | // Actions with no targets 115 | const noTargets = commands.filter((command) => !command.targetId && !command.point); 116 | if (noTargets.length > 0) { 117 | actionsApi.orderUnits( 118 | noTargets.map((action) => action.unitId), 119 | commandType, 120 | ); 121 | } 122 | }); 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /src/bot/logic/mission/mission.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ActionsApi, 3 | GameApi, 4 | GameObjectData, 5 | PlayerData, 6 | TechnoRules, 7 | Tile, 8 | UnitData, 9 | Vector2, 10 | } from "@chronodivide/game-api"; 11 | import { MatchAwareness } from "../awareness.js"; 12 | import { DebugLogger } from "../common/utils.js"; 13 | import { ActionBatcher } from "./actionBatcher.js"; 14 | import { getDistanceBetweenTileAndPoint } from "../map/map.js"; 15 | import { getCachedTechnoRules } from "../common/rulesCache.js"; 16 | 17 | const calculateCenterOfMass: (unitTiles: Tile[]) => { 18 | centerOfMass: Vector2; 19 | maxDistance: number; 20 | } | null = (unitTiles) => { 21 | if (unitTiles.length === 0) { 22 | return null; 23 | } 24 | // TODO: use median here 25 | const sums = unitTiles.reduce( 26 | ({ x, y }, tile) => { 27 | return { 28 | x: x + (tile?.rx || 0), 29 | y: y + (tile?.ry || 0), 30 | }; 31 | }, 32 | { x: 0, y: 0 }, 33 | ); 34 | const centerOfMass = new Vector2(Math.round(sums.x / unitTiles.length), Math.round(sums.y / unitTiles.length)); 35 | 36 | // max distance of units to the center of mass 37 | const distances = unitTiles.map((tile) => getDistanceBetweenTileAndPoint(tile, centerOfMass)); 38 | const maxDistance = Math.max(...distances); 39 | return { centerOfMass, maxDistance }; 40 | }; 41 | // AI starts Missions based on heuristics. 42 | export abstract class Mission { 43 | private active = true; 44 | private unitIds: number[] = []; 45 | private centerOfMass: Vector2 | null = null; 46 | private maxDistanceToCenterOfMass: number | null = null; 47 | 48 | private onFinishListeners: ((unitIds: number[], reason: FailureReasons) => void)[] = []; 49 | 50 | constructor( 51 | private uniqueName: string, 52 | protected logger: DebugLogger, 53 | ) {} 54 | 55 | // TODO call this 56 | protected updateCenterOfMass(gameApi: GameApi) { 57 | const unitTiles = this.unitIds 58 | .map((unitId) => gameApi.getGameObjectData(unitId)) 59 | .map((unit) => unit?.tile) 60 | .filter((tile) => !!tile) as Tile[]; 61 | const tileMetrics = calculateCenterOfMass(unitTiles); 62 | if (tileMetrics) { 63 | this.centerOfMass = tileMetrics.centerOfMass; 64 | this.maxDistanceToCenterOfMass = tileMetrics.maxDistance; 65 | } else { 66 | this.centerOfMass = null; 67 | this.maxDistanceToCenterOfMass = null; 68 | } 69 | } 70 | 71 | public onAiUpdate( 72 | gameApi: GameApi, 73 | actionsApi: ActionsApi, 74 | playerData: PlayerData, 75 | matchAwareness: MatchAwareness, 76 | actionBatcher: ActionBatcher, 77 | ): MissionAction { 78 | this.updateCenterOfMass(gameApi); 79 | return this._onAiUpdate(gameApi, actionsApi, playerData, matchAwareness, actionBatcher); 80 | } 81 | 82 | // TODO: fix this weird indirection 83 | abstract _onAiUpdate( 84 | gameApi: GameApi, 85 | actionsApi: ActionsApi, 86 | playerData: PlayerData, 87 | matchAwareness: MatchAwareness, 88 | actionBatcher: ActionBatcher, 89 | ): MissionAction; 90 | 91 | isActive(): boolean { 92 | return this.active; 93 | } 94 | 95 | public getUnitIds(): number[] { 96 | return this.unitIds; 97 | } 98 | 99 | public removeUnit(unitIdToRemove: number): void { 100 | this.unitIds = this.unitIds.filter((unitId) => unitId != unitIdToRemove); 101 | } 102 | 103 | public addUnit(unitIdToAdd: number): void { 104 | this.unitIds.push(unitIdToAdd); 105 | } 106 | 107 | // Note: don't call this unless you REALLY need the UnitData instead of the GameObjectData. 108 | public getUnits(gameApi: GameApi): UnitData[] { 109 | return this.unitIds 110 | .map((unitId) => gameApi.getUnitData(unitId)) 111 | .filter((unit) => unit != null) 112 | .map((unit) => unit!); 113 | } 114 | 115 | // returns GameObjectData, which is significantly faster to retrieve. 116 | public getUnitsGameObjectData(gameApi: GameApi): GameObjectData[] { 117 | return this.unitIds 118 | .map((unitId) => gameApi.getGameObjectData(unitId)) 119 | .filter((unit) => unit != null) 120 | .map((unit) => unit!); 121 | } 122 | 123 | public getUnitsOfTypes(gameApi: GameApi, ...names: string[]): UnitData[] { 124 | return this.unitIds 125 | .map((unitId) => gameApi.getUnitData(unitId)) 126 | .filter((unit) => !!unit && names.includes(unit.name)) 127 | .map((unit) => unit!); 128 | } 129 | 130 | public getUnitsMatchingByRule(gameApi: GameApi, filter: (r: TechnoRules) => boolean): number[] { 131 | type ValidEntry = { 132 | unitId: number; 133 | rules: TechnoRules; 134 | }; 135 | return this.unitIds 136 | .map((unitId) => ({ 137 | unitId, 138 | rules: getCachedTechnoRules(gameApi, unitId), 139 | })) 140 | .filter((entry): entry is ValidEntry => entry.rules !== null) 141 | .filter(({ rules }) => filter(rules)) 142 | .map(({ unitId }) => unitId); 143 | } 144 | 145 | public getCenterOfMass() { 146 | return this.centerOfMass; 147 | } 148 | 149 | public getMaxDistanceToCenterOfMass() { 150 | return this.maxDistanceToCenterOfMass; 151 | } 152 | 153 | getUniqueName(): string { 154 | return this.uniqueName; 155 | } 156 | 157 | // Don't call this from the mission itself 158 | onFinish(reason: FailureReasons): void { 159 | this.onFinishListeners.forEach((listener) => listener(this.unitIds, reason)); 160 | this.active = false; 161 | } 162 | 163 | /** 164 | * Declare a callback that is executed when the mission is disbanded for whatever reason. 165 | */ 166 | then(onFinishHandler: (unitIds: number[], reason: FailureReasons) => void): Mission { 167 | this.onFinishListeners.push(onFinishHandler); 168 | return this; 169 | } 170 | 171 | abstract getGlobalDebugText(): string | undefined; 172 | 173 | /** 174 | * Determines whether units can be stolen from this mission by other missions with higher priority. 175 | */ 176 | public isUnitsLocked(): boolean { 177 | return true; 178 | } 179 | 180 | abstract getPriority(): number; 181 | } 182 | 183 | export type MissionWithAction = { 184 | mission: Mission; 185 | action: T; 186 | }; 187 | 188 | export type MissionActionNoop = { 189 | type: "noop"; 190 | }; 191 | 192 | export type MissionActionDisband = { 193 | type: "disband"; 194 | reason: any | null; 195 | }; 196 | 197 | export type MissionActionRequestUnits = { 198 | type: "request"; 199 | unitNames: string[]; 200 | priority: number; 201 | }; 202 | 203 | export type MissionActionRequestSpecificUnits = { 204 | type: "requestSpecific"; 205 | unitIds: number[]; 206 | priority: number; 207 | }; 208 | 209 | export type MissionActionGrabFreeCombatants = { 210 | type: "requestCombatants"; 211 | point: Vector2; 212 | radius: number; 213 | }; 214 | 215 | export type MissionActionReleaseUnits = { 216 | type: "releaseUnits"; 217 | unitIds: number[]; 218 | }; 219 | 220 | export const noop = () => 221 | ({ 222 | type: "noop", 223 | }) as MissionActionNoop; 224 | 225 | export const disbandMission = (reason?: any) => ({ type: "disband", reason }) as MissionActionDisband; 226 | export const isDisbandMission = (a: MissionWithAction): a is MissionWithAction => 227 | a.action.type === "disband"; 228 | 229 | export const requestUnits = (unitNames: string[], priority: number) => 230 | ({ type: "request", unitNames, priority }) as MissionActionRequestUnits; 231 | export const isRequestUnits = ( 232 | a: MissionWithAction, 233 | ): a is MissionWithAction => a.action.type === "request"; 234 | 235 | export const requestSpecificUnits = (unitIds: number[], priority: number) => 236 | ({ type: "requestSpecific", unitIds, priority }) as MissionActionRequestSpecificUnits; 237 | export const isRequestSpecificUnits = ( 238 | a: MissionWithAction, 239 | ): a is MissionWithAction => a.action.type === "requestSpecific"; 240 | 241 | export const grabCombatants = (point: Vector2, radius: number) => 242 | ({ type: "requestCombatants", point, radius }) as MissionActionGrabFreeCombatants; 243 | export const isGrabCombatants = ( 244 | a: MissionWithAction, 245 | ): a is MissionWithAction => a.action.type === "requestCombatants"; 246 | 247 | export const releaseUnits = (unitIds: number[]) => ({ type: "releaseUnits", unitIds }) as MissionActionReleaseUnits; 248 | export const isReleaseUnits = ( 249 | a: MissionWithAction, 250 | ): a is MissionWithAction => a.action.type === "releaseUnits"; 251 | 252 | export type MissionAction = 253 | | MissionActionNoop 254 | | MissionActionDisband 255 | | MissionActionRequestUnits 256 | | MissionActionRequestSpecificUnits 257 | | MissionActionGrabFreeCombatants 258 | | MissionActionReleaseUnits; 259 | -------------------------------------------------------------------------------- /src/bot/logic/mission/missionFactories.ts: -------------------------------------------------------------------------------- 1 | import { GameApi, PlayerData, ProductionApi } from "@chronodivide/game-api"; 2 | import { ExpansionMissionFactory } from "./missions/expansionMission.js"; 3 | import { Mission } from "./mission.js"; 4 | import { MatchAwareness } from "../awareness.js"; 5 | import { ScoutingMissionFactory } from "./missions/scoutingMission.js"; 6 | import { DynamicAttackMissionFactory } from "./missions/attackMission.js"; 7 | import { MissionController } from "./missionController.js"; 8 | import { DefenceMissionFactory } from "./missions/defenceMission.js"; 9 | import { DebugLogger } from "../common/utils.js"; 10 | import { EngineerMissionFactory } from "./missions/engineerMission.js"; 11 | 12 | export interface MissionFactory { 13 | getName(): string; 14 | 15 | /** 16 | * Queries the factory for new missions to be spawned. 17 | * 18 | * @param gameApi 19 | * @param productionApi 20 | * @param playerData 21 | * @param matchAwareness 22 | * @param missionController 23 | */ 24 | maybeCreateMissions( 25 | gameApi: GameApi, 26 | productionApi: ProductionApi, 27 | playerData: PlayerData, 28 | matchAwareness: MatchAwareness, 29 | missionController: MissionController, 30 | logger: DebugLogger, 31 | ): void; 32 | 33 | /** 34 | * Called when any mission fails - can be used to trigger another mission in response. 35 | */ 36 | onMissionFailed( 37 | gameApi: GameApi, 38 | playerData: PlayerData, 39 | matchAwareness: MatchAwareness, 40 | failedMission: Mission, 41 | failureReason: any, 42 | missionController: MissionController, 43 | logger: DebugLogger, 44 | ): void; 45 | } 46 | 47 | export const createBaseMissionFactories = () => [ 48 | new ExpansionMissionFactory(), 49 | new ScoutingMissionFactory(), 50 | new DefenceMissionFactory(), 51 | new EngineerMissionFactory(), 52 | ]; 53 | -------------------------------------------------------------------------------- /src/bot/logic/mission/missions/attackMission.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ActionsApi, 3 | GameApi, 4 | ObjectType, 5 | PlayerData, 6 | ProductionApi, 7 | SideType, 8 | UnitData, 9 | Vector2, 10 | } from "@chronodivide/game-api"; 11 | import { CombatSquad } from "./squads/combatSquad.js"; 12 | import { Mission, MissionAction, disbandMission, noop, requestUnits } from "../mission.js"; 13 | import { MissionFactory } from "../missionFactories.js"; 14 | import { MatchAwareness } from "../../awareness.js"; 15 | import { MissionController } from "../missionController.js"; 16 | import { RetreatMission } from "./retreatMission.js"; 17 | import { DebugLogger, countBy, isOwnedByNeutral, maxBy } from "../../common/utils.js"; 18 | import { ActionBatcher } from "../actionBatcher.js"; 19 | import { getSovietComposition } from "../../composition/sovietCompositions.js"; 20 | import { getAlliedCompositions } from "../../composition/alliedCompositions.js"; 21 | import { UnitComposition } from "../../composition/common.js"; 22 | import { manageMoveMicro } from "./squads/common.js"; 23 | 24 | export enum AttackFailReason { 25 | NoTargets = 0, 26 | DefenceTooStrong = 1, 27 | } 28 | 29 | enum AttackMissionState { 30 | Preparing = 0, 31 | Attacking = 1, 32 | Retreating = 2, 33 | } 34 | 35 | const NO_TARGET_RETARGET_TICKS = 450; 36 | const NO_TARGET_IDLE_TIMEOUT_TICKS = 900; 37 | 38 | function calculateTargetComposition( 39 | gameApi: GameApi, 40 | playerData: PlayerData, 41 | matchAwareness: MatchAwareness, 42 | ): UnitComposition { 43 | if (!playerData.country) { 44 | throw new Error(`player ${playerData.name} has no country`); 45 | } else if (playerData.country.side === SideType.Nod) { 46 | return getSovietComposition(gameApi, playerData, matchAwareness); 47 | } else { 48 | return getAlliedCompositions(gameApi, playerData, matchAwareness); 49 | } 50 | } 51 | 52 | const ATTACK_MISSION_PRIORITY_RAMP = 1.01; 53 | const ATTACK_MISSION_MAX_PRIORITY = 50; 54 | 55 | /** 56 | * A mission that tries to attack a certain area. 57 | */ 58 | export class AttackMission extends Mission { 59 | private squad: CombatSquad; 60 | 61 | private lastTargetSeenAt = 0; 62 | private hasPickedNewTarget: boolean = false; 63 | 64 | private state: AttackMissionState = AttackMissionState.Preparing; 65 | 66 | constructor( 67 | uniqueName: string, 68 | private priority: number, 69 | rallyArea: Vector2, 70 | private attackArea: Vector2, 71 | private radius: number, 72 | logger: DebugLogger, 73 | private composition: UnitComposition, 74 | private dissolveUnfulfilledAt: number | null = null, 75 | ) { 76 | super(uniqueName, logger); 77 | this.squad = new CombatSquad(rallyArea, attackArea, radius); 78 | } 79 | 80 | _onAiUpdate( 81 | gameApi: GameApi, 82 | actionsApi: ActionsApi, 83 | playerData: PlayerData, 84 | matchAwareness: MatchAwareness, 85 | actionBatcher: ActionBatcher, 86 | ): MissionAction { 87 | switch (this.state) { 88 | case AttackMissionState.Preparing: 89 | return this.handlePreparingState(gameApi, actionsApi, playerData, matchAwareness, actionBatcher); 90 | case AttackMissionState.Attacking: 91 | return this.handleAttackingState(gameApi, actionsApi, playerData, matchAwareness, actionBatcher); 92 | case AttackMissionState.Retreating: 93 | return this.handleRetreatingState(gameApi, actionsApi, playerData, matchAwareness, actionBatcher); 94 | } 95 | } 96 | 97 | private handlePreparingState( 98 | gameApi: GameApi, 99 | actionsApi: ActionsApi, 100 | playerData: PlayerData, 101 | matchAwareness: MatchAwareness, 102 | actionBatcher: ActionBatcher, 103 | ) { 104 | const currentComposition: UnitComposition = countBy(this.getUnitsGameObjectData(gameApi), (unit) => unit.name); 105 | 106 | const missingUnits = Object.entries(this.composition).filter(([unitType, targetAmount]) => { 107 | return !currentComposition[unitType] || currentComposition[unitType] < targetAmount; 108 | }); 109 | 110 | if (this.dissolveUnfulfilledAt && gameApi.getCurrentTick() > this.dissolveUnfulfilledAt) { 111 | return disbandMission(); 112 | } 113 | 114 | if (missingUnits.length > 0) { 115 | this.priority = Math.min(this.priority * ATTACK_MISSION_PRIORITY_RAMP, ATTACK_MISSION_MAX_PRIORITY); 116 | return requestUnits( 117 | missingUnits.map(([unitName]) => unitName), 118 | this.priority, 119 | ); 120 | } else { 121 | this.priority = ATTACK_MISSION_INITIAL_PRIORITY; 122 | this.state = AttackMissionState.Attacking; 123 | return noop(); 124 | } 125 | } 126 | 127 | private handleAttackingState( 128 | gameApi: GameApi, 129 | actionsApi: ActionsApi, 130 | playerData: PlayerData, 131 | matchAwareness: MatchAwareness, 132 | actionBatcher: ActionBatcher, 133 | ) { 134 | if (this.getUnitIds().length === 0) { 135 | // TODO: disband directly (we no longer retreat when losing) 136 | this.state = AttackMissionState.Retreating; 137 | return noop(); 138 | } 139 | 140 | const foundTargets = matchAwareness 141 | .getHostilesNearPoint2d(this.attackArea, this.radius) 142 | .map((unit) => gameApi.getUnitData(unit.unitId)) 143 | .filter((unit) => !isOwnedByNeutral(unit)) as UnitData[]; 144 | 145 | const update = this.squad.onAiUpdate( 146 | gameApi, 147 | actionsApi, 148 | actionBatcher, 149 | playerData, 150 | this, 151 | matchAwareness, 152 | this.logger, 153 | ); 154 | 155 | if (update.type !== "noop") { 156 | return update; 157 | } 158 | 159 | if (foundTargets.length > 0) { 160 | this.lastTargetSeenAt = gameApi.getCurrentTick(); 161 | this.hasPickedNewTarget = false; 162 | } else if (gameApi.getCurrentTick() > this.lastTargetSeenAt + NO_TARGET_IDLE_TIMEOUT_TICKS) { 163 | return disbandMission(AttackFailReason.NoTargets); 164 | } else if ( 165 | !this.hasPickedNewTarget && 166 | gameApi.getCurrentTick() > this.lastTargetSeenAt + NO_TARGET_RETARGET_TICKS 167 | ) { 168 | const newTarget = generateTarget(gameApi, playerData, matchAwareness); 169 | if (newTarget) { 170 | this.squad.setAttackArea(newTarget); 171 | this.hasPickedNewTarget = true; 172 | } 173 | } 174 | 175 | return noop(); 176 | } 177 | 178 | private handleRetreatingState( 179 | gameApi: GameApi, 180 | actionsApi: ActionsApi, 181 | playerData: PlayerData, 182 | matchAwareness: MatchAwareness, 183 | actionBatcher: ActionBatcher, 184 | ) { 185 | this.getUnits(gameApi).forEach((unitId) => { 186 | const moveAction = manageMoveMicro(unitId, matchAwareness.getMainRallyPoint()); 187 | if (moveAction) { 188 | actionBatcher.push(moveAction); 189 | } 190 | }); 191 | return disbandMission(); 192 | } 193 | 194 | public getGlobalDebugText(): string | undefined { 195 | return this.squad.getGlobalDebugText() ?? ""; 196 | } 197 | 198 | public getState() { 199 | return this.state; 200 | } 201 | 202 | // This mission can give up its units while preparing. 203 | public isUnitsLocked(): boolean { 204 | return this.state !== AttackMissionState.Preparing; 205 | } 206 | 207 | public getPriority() { 208 | return this.priority; 209 | } 210 | } 211 | 212 | // Calculates the weight for initiating an attack on the position of a unit or building. 213 | // This is separate from unit micro; the squad will be ordered to attack in the vicinity of the point. 214 | const getTargetWeight: (unitData: UnitData, tryFocusHarvester: boolean) => number = (unitData, tryFocusHarvester) => { 215 | if (tryFocusHarvester && unitData.rules.harvester) { 216 | return 100000; 217 | } else if (unitData.type === ObjectType.Building) { 218 | return unitData.maxHitPoints * 10; 219 | } else { 220 | return unitData.maxHitPoints; 221 | } 222 | }; 223 | 224 | export function generateTarget( 225 | gameApi: GameApi, 226 | playerData: PlayerData, 227 | matchAwareness: MatchAwareness, 228 | includeBaseLocations: boolean = false, 229 | ): Vector2 | null { 230 | // Randomly decide between harvester and base. 231 | try { 232 | const tryFocusHarvester = gameApi.generateRandomInt(0, 1) === 0; 233 | const enemyUnits = gameApi 234 | .getVisibleUnits(playerData.name, "enemy") 235 | .map((unitId) => gameApi.getUnitData(unitId)) 236 | .filter((u) => !!u && u.hitPoints > 0 && gameApi.getPlayerData(u.owner).isCombatant) as UnitData[]; 237 | 238 | const maxUnit = maxBy(enemyUnits, (u) => getTargetWeight(u, tryFocusHarvester)); 239 | if (maxUnit) { 240 | return new Vector2(maxUnit.tile.rx, maxUnit.tile.ry); 241 | } 242 | if (includeBaseLocations) { 243 | const mapApi = gameApi.mapApi; 244 | const enemyPlayers = gameApi 245 | .getPlayers() 246 | .map(gameApi.getPlayerData) 247 | .filter((otherPlayer) => !gameApi.areAlliedPlayers(playerData.name, otherPlayer.name)); 248 | 249 | const unexploredEnemyLocations = enemyPlayers.filter((otherPlayer) => { 250 | const tile = mapApi.getTile(otherPlayer.startLocation.x, otherPlayer.startLocation.y); 251 | if (!tile) { 252 | return false; 253 | } 254 | return !mapApi.isVisibleTile(tile, playerData.name); 255 | }); 256 | if (unexploredEnemyLocations.length > 0) { 257 | const idx = gameApi.generateRandomInt(0, unexploredEnemyLocations.length - 1); 258 | return unexploredEnemyLocations[idx].startLocation; 259 | } 260 | } 261 | } catch (err) { 262 | // There's a crash here when accessing a building that got destroyed. Will catch and ignore or now. 263 | return null; 264 | } 265 | return null; 266 | } 267 | 268 | // Number of ticks between attacking visible targets. 269 | const VISIBLE_TARGET_ATTACK_COOLDOWN_TICKS = 120; 270 | 271 | // Number of ticks between attacking "bases" (enemy starting locations). 272 | const BASE_ATTACK_COOLDOWN_TICKS = 1800; 273 | 274 | const ATTACK_MISSION_INITIAL_PRIORITY = 1; 275 | 276 | export class DynamicAttackMissionFactory implements MissionFactory { 277 | constructor(private lastAttackAt: number = -VISIBLE_TARGET_ATTACK_COOLDOWN_TICKS) {} 278 | 279 | getName(): string { 280 | return "DynamicAttackMissionFactory"; 281 | } 282 | 283 | maybeCreateMissions( 284 | gameApi: GameApi, 285 | productionApi: ProductionApi, 286 | playerData: PlayerData, 287 | matchAwareness: MatchAwareness, 288 | missionController: MissionController, 289 | logger: DebugLogger, 290 | ): void { 291 | if (gameApi.getCurrentTick() < this.lastAttackAt + VISIBLE_TARGET_ATTACK_COOLDOWN_TICKS) { 292 | return; 293 | } 294 | 295 | // can only have one attack 'preparing' at once. 296 | if ( 297 | missionController 298 | .getMissions() 299 | .some( 300 | (mission): mission is AttackMission => 301 | mission instanceof AttackMission && mission.getState() === AttackMissionState.Preparing, 302 | ) 303 | ) { 304 | return; 305 | } 306 | 307 | const attackRadius = 10; 308 | 309 | const includeEnemyBases = gameApi.getCurrentTick() > this.lastAttackAt + BASE_ATTACK_COOLDOWN_TICKS; 310 | 311 | const attackArea = generateTarget(gameApi, playerData, matchAwareness, includeEnemyBases); 312 | 313 | if (!attackArea) { 314 | return; 315 | } 316 | 317 | const squadName = "attack_" + gameApi.getCurrentTick(); 318 | 319 | const composition: UnitComposition = calculateTargetComposition(gameApi, playerData, matchAwareness); 320 | 321 | const tryAttack = missionController.addMission( 322 | new AttackMission( 323 | squadName, 324 | ATTACK_MISSION_INITIAL_PRIORITY, 325 | matchAwareness.getMainRallyPoint(), 326 | attackArea, 327 | attackRadius, 328 | logger, 329 | composition, 330 | ).then((unitIds, reason) => { 331 | missionController.addMission( 332 | new RetreatMission( 333 | "retreat-from-" + squadName + gameApi.getCurrentTick(), 334 | matchAwareness.getMainRallyPoint(), 335 | unitIds, 336 | logger, 337 | ), 338 | ); 339 | }), 340 | ); 341 | if (tryAttack) { 342 | this.lastAttackAt = gameApi.getCurrentTick(); 343 | } 344 | } 345 | 346 | onMissionFailed( 347 | gameApi: GameApi, 348 | playerData: PlayerData, 349 | matchAwareness: MatchAwareness, 350 | failedMission: Mission, 351 | failureReason: any, 352 | missionController: MissionController, 353 | ): void {} 354 | } 355 | -------------------------------------------------------------------------------- /src/bot/logic/mission/missions/defenceMission.ts: -------------------------------------------------------------------------------- 1 | import { ActionsApi, GameApi, PlayerData, ProductionApi, UnitData, Vector2 } from "@chronodivide/game-api"; 2 | import { MatchAwareness } from "../../awareness.js"; 3 | import { MissionController } from "../missionController.js"; 4 | import { Mission, MissionAction, grabCombatants, noop, releaseUnits, requestUnits } from "../mission.js"; 5 | import { MissionFactory } from "../missionFactories.js"; 6 | import { CombatSquad } from "./squads/combatSquad.js"; 7 | import { DebugLogger, isOwnedByNeutral } from "../../common/utils.js"; 8 | import { ActionBatcher } from "../actionBatcher.js"; 9 | 10 | export const MAX_PRIORITY = 100; 11 | export const PRIORITY_INCREASE_PER_TICK_RATIO = 1.025; 12 | 13 | /** 14 | * A mission that tries to defend a certain area. 15 | */ 16 | export class DefenceMission extends Mission { 17 | private squad: CombatSquad; 18 | 19 | constructor( 20 | uniqueName: string, 21 | private priority: number, 22 | rallyArea: Vector2, 23 | private defenceArea: Vector2, 24 | private radius: number, 25 | logger: DebugLogger, 26 | ) { 27 | super(uniqueName, logger); 28 | this.squad = new CombatSquad(rallyArea, defenceArea, radius); 29 | } 30 | 31 | _onAiUpdate( 32 | gameApi: GameApi, 33 | actionsApi: ActionsApi, 34 | playerData: PlayerData, 35 | matchAwareness: MatchAwareness, 36 | actionBatcher: ActionBatcher, 37 | ): MissionAction { 38 | // Dispatch missions. 39 | const foundTargets = matchAwareness 40 | .getHostilesNearPoint2d(this.defenceArea, this.radius) 41 | .map((unit) => gameApi.getUnitData(unit.unitId)) 42 | .filter((unit) => !isOwnedByNeutral(unit)) as UnitData[]; 43 | 44 | const update = this.squad.onAiUpdate( 45 | gameApi, 46 | actionsApi, 47 | actionBatcher, 48 | playerData, 49 | this, 50 | matchAwareness, 51 | this.logger, 52 | ); 53 | 54 | if (update.type !== "noop") { 55 | return update; 56 | } 57 | 58 | if (foundTargets.length === 0) { 59 | this.priority = 0; 60 | if (this.getUnitIds().length > 0) { 61 | this.logger(`(Defence Mission ${this.getUniqueName()}): No targets found, releasing units.`); 62 | return releaseUnits(this.getUnitIds()); 63 | } else { 64 | return noop(); 65 | } 66 | } else { 67 | const targetUnit = foundTargets[0]; 68 | this.logger( 69 | `(Defence Mission ${this.getUniqueName()}): Focused on target ${targetUnit?.name} (${ 70 | foundTargets.length 71 | } found in area ${this.radius})`, 72 | ); 73 | this.squad.setAttackArea(new Vector2(foundTargets[0].tile.rx, foundTargets[0].tile.ry)); 74 | this.priority = MAX_PRIORITY; // Math.min(MAX_PRIORITY, this.priority * PRIORITY_INCREASE_PER_TICK_RATIO); 75 | return grabCombatants(playerData.startLocation, this.priority); 76 | } 77 | //return requestUnits(["E1", "E2", "FV", "HTK", "MTNK", "HTNK"], this.priority); 78 | } 79 | 80 | public getGlobalDebugText(): string | undefined { 81 | return this.squad.getGlobalDebugText() ?? ""; 82 | } 83 | 84 | public getPriority() { 85 | return this.priority; 86 | } 87 | } 88 | 89 | const DEFENCE_CHECK_TICKS = 30; 90 | 91 | // Starting radius around the player's base to trigger defense. 92 | const DEFENCE_STARTING_RADIUS = 10; 93 | // Every game tick, we increase the defendable area by this amount. 94 | const DEFENCE_RADIUS_INCREASE_PER_GAME_TICK = 0.001; 95 | 96 | export class DefenceMissionFactory implements MissionFactory { 97 | private lastDefenceCheckAt = 0; 98 | 99 | constructor() {} 100 | 101 | getName(): string { 102 | return "DefenceMissionFactory"; 103 | } 104 | 105 | maybeCreateMissions( 106 | gameApi: GameApi, 107 | productionApi: ProductionApi, 108 | playerData: PlayerData, 109 | matchAwareness: MatchAwareness, 110 | missionController: MissionController, 111 | logger: DebugLogger, 112 | ): void { 113 | if (gameApi.getCurrentTick() < this.lastDefenceCheckAt + DEFENCE_CHECK_TICKS) { 114 | return; 115 | } 116 | this.lastDefenceCheckAt = gameApi.getCurrentTick(); 117 | 118 | const defendableRadius = 119 | DEFENCE_STARTING_RADIUS + DEFENCE_RADIUS_INCREASE_PER_GAME_TICK * gameApi.getCurrentTick(); 120 | const enemiesNearSpawn = matchAwareness 121 | .getHostilesNearPoint2d(playerData.startLocation, defendableRadius) 122 | .map((unit) => gameApi.getUnitData(unit.unitId)) 123 | .filter((unit) => !isOwnedByNeutral(unit)) as UnitData[]; 124 | 125 | if (enemiesNearSpawn.length > 0) { 126 | logger( 127 | `Starting defence mission, ${ 128 | enemiesNearSpawn.length 129 | } found in radius ${defendableRadius} (tick ${gameApi.getCurrentTick()})`, 130 | ); 131 | missionController.addMission( 132 | new DefenceMission( 133 | "globalDefence", 134 | 10, 135 | matchAwareness.getMainRallyPoint(), 136 | playerData.startLocation, 137 | defendableRadius * 1.2, 138 | logger, 139 | ), 140 | ); 141 | } 142 | } 143 | 144 | onMissionFailed( 145 | gameApi: GameApi, 146 | playerData: PlayerData, 147 | matchAwareness: MatchAwareness, 148 | failedMission: Mission, 149 | failureReason: undefined, 150 | missionController: MissionController, 151 | ): void {} 152 | } 153 | -------------------------------------------------------------------------------- /src/bot/logic/mission/missions/engineerMission.ts: -------------------------------------------------------------------------------- 1 | import { ActionsApi, GameApi, OrderType, PlayerData, ProductionApi } from "@chronodivide/game-api"; 2 | import { Mission, MissionAction, disbandMission, noop, requestUnits } from "../mission.js"; 3 | import { MissionFactory } from "../missionFactories.js"; 4 | import { MatchAwareness } from "../../awareness.js"; 5 | import { MissionController } from "../missionController.js"; 6 | import { DebugLogger } from "../../common/utils.js"; 7 | import { ActionBatcher } from "../actionBatcher.js"; 8 | 9 | const CAPTURE_COOLDOWN_TICKS = 30; 10 | 11 | /** 12 | * A mission that tries to send an engineer into a building (e.g. to capture tech building or repair bridge) 13 | */ 14 | export class EngineerMission extends Mission { 15 | private hasAttemptedCaptureWith: { 16 | unitId: number; 17 | gameTick: number; 18 | } | null = null; 19 | 20 | constructor( 21 | uniqueName: string, 22 | private priority: number, 23 | private captureTargetId: number, 24 | logger: DebugLogger, 25 | ) { 26 | super(uniqueName, logger); 27 | } 28 | 29 | public _onAiUpdate( 30 | gameApi: GameApi, 31 | actionsApi: ActionsApi, 32 | playerData: PlayerData, 33 | matchAwareness: MatchAwareness, 34 | actionBatcher: ActionBatcher, 35 | ): MissionAction { 36 | const engineerTypes = ["ENGINEER", "SENGINEER"]; 37 | const engineers = this.getUnitsOfTypes(gameApi, ...engineerTypes); 38 | if (engineers.length === 0) { 39 | // Perhaps we deployed already (or the unit was destroyed), end the mission. 40 | if (this.hasAttemptedCaptureWith !== null) { 41 | return disbandMission(); 42 | } 43 | return requestUnits(engineerTypes, this.priority); 44 | } else if ( 45 | !this.hasAttemptedCaptureWith || 46 | gameApi.getCurrentTick() > this.hasAttemptedCaptureWith.gameTick + CAPTURE_COOLDOWN_TICKS 47 | ) { 48 | actionsApi.orderUnits( 49 | engineers.map((engineer) => engineer.id), 50 | OrderType.Capture, 51 | this.captureTargetId, 52 | ); 53 | // Add a cooldown to deploy attempts. 54 | this.hasAttemptedCaptureWith = { 55 | unitId: engineers[0].id, 56 | gameTick: gameApi.getCurrentTick(), 57 | }; 58 | } 59 | return noop(); 60 | } 61 | 62 | public getGlobalDebugText(): string | undefined { 63 | return undefined; 64 | } 65 | 66 | public getPriority() { 67 | return this.priority; 68 | } 69 | } 70 | 71 | // Only try to capture tech buildings within this radius of the starting point. 72 | const MAX_TECH_CAPTURE_RADIUS = 50; 73 | 74 | const TECH_CHECK_INTERVAL_TICKS = 300; 75 | const MAX_ATTEMPTS_PER_TECH_STRUCTURE = 3; 76 | 77 | export class EngineerMissionFactory implements MissionFactory { 78 | private lastCheckAt = 0; 79 | private attemptCount = new Map(); 80 | 81 | getName(): string { 82 | return "EngineerMissionFactory"; 83 | } 84 | 85 | maybeCreateMissions( 86 | gameApi: GameApi, 87 | productionApi: ProductionApi, 88 | playerData: PlayerData, 89 | matchAwareness: MatchAwareness, 90 | missionController: MissionController, 91 | logger: DebugLogger, 92 | ): void { 93 | if (!(gameApi.getCurrentTick() > this.lastCheckAt + TECH_CHECK_INTERVAL_TICKS)) { 94 | return; 95 | } 96 | this.lastCheckAt = gameApi.getCurrentTick(); 97 | const eligibleTechBuildings = gameApi.getVisibleUnits( 98 | playerData.name, 99 | "hostile", 100 | (r) => r.capturable && r.produceCashAmount > 0, 101 | ); 102 | 103 | eligibleTechBuildings 104 | .filter((b) => gameApi.getGameObjectData(b)?.hitPoints ?? 0 > 0) 105 | .filter((b) => this.attemptCount.get(b) ?? 0 <= MAX_ATTEMPTS_PER_TECH_STRUCTURE) 106 | .forEach((techBuildingId) => { 107 | if ( 108 | missionController.addMission( 109 | new EngineerMission("capture-" + techBuildingId, 100, techBuildingId, logger), 110 | ) 111 | ) { 112 | this.attemptCount.set(techBuildingId, (this.attemptCount.get(techBuildingId) ?? 0) + 1); 113 | } 114 | }); 115 | } 116 | 117 | onMissionFailed( 118 | gameApi: GameApi, 119 | playerData: PlayerData, 120 | matchAwareness: MatchAwareness, 121 | failedMission: Mission, 122 | failureReason: undefined, 123 | missionController: MissionController, 124 | ): void {} 125 | } 126 | -------------------------------------------------------------------------------- /src/bot/logic/mission/missions/expansionMission.ts: -------------------------------------------------------------------------------- 1 | import { ActionsApi, GameApi, OrderType, PlayerData, ProductionApi } from "@chronodivide/game-api"; 2 | import { Mission, MissionAction, disbandMission, noop, requestSpecificUnits, requestUnits } from "../mission.js"; 3 | import { MissionFactory } from "../missionFactories.js"; 4 | import { MatchAwareness } from "../../awareness.js"; 5 | import { MissionController } from "../missionController.js"; 6 | import { DebugLogger } from "../../common/utils.js"; 7 | import { ActionBatcher } from "../actionBatcher.js"; 8 | 9 | const DEPLOY_COOLDOWN_TICKS = 30; 10 | 11 | /** 12 | * A mission that tries to create an MCV (if it doesn't exist) and deploy it somewhere it can be deployed. 13 | */ 14 | export class ExpansionMission extends Mission { 15 | private hasAttemptedDeployWith: { 16 | unitId: number; 17 | gameTick: number; 18 | } | null = null; 19 | 20 | constructor( 21 | uniqueName: string, 22 | private priority: number, 23 | private selectedMcv: number | null, 24 | logger: DebugLogger, 25 | ) { 26 | super(uniqueName, logger); 27 | } 28 | 29 | public _onAiUpdate( 30 | gameApi: GameApi, 31 | actionsApi: ActionsApi, 32 | playerData: PlayerData, 33 | matchAwareness: MatchAwareness, 34 | actionBatcher: ActionBatcher, 35 | ): MissionAction { 36 | const mcvTypes = ["AMCV", "SMCV"]; 37 | const mcvs = this.getUnitsOfTypes(gameApi, ...mcvTypes); 38 | if (mcvs.length === 0) { 39 | // Perhaps we deployed already (or the unit was destroyed), end the mission. 40 | if (this.hasAttemptedDeployWith !== null) { 41 | return disbandMission(); 42 | } 43 | // We need an mcv! 44 | if (this.selectedMcv) { 45 | return requestSpecificUnits([this.selectedMcv], this.priority); 46 | } else { 47 | return requestUnits(mcvTypes, this.priority); 48 | } 49 | } else if ( 50 | !this.hasAttemptedDeployWith || 51 | gameApi.getCurrentTick() > this.hasAttemptedDeployWith.gameTick + DEPLOY_COOLDOWN_TICKS 52 | ) { 53 | actionsApi.orderUnits( 54 | mcvs.map((mcv) => mcv.id), 55 | OrderType.DeploySelected, 56 | ); 57 | // Add a cooldown to deploy attempts. 58 | this.hasAttemptedDeployWith = { 59 | unitId: mcvs[0].id, 60 | gameTick: gameApi.getCurrentTick(), 61 | }; 62 | } 63 | return noop(); 64 | } 65 | 66 | public getGlobalDebugText(): string | undefined { 67 | return `Expand with MCV ${this.selectedMcv}`; 68 | } 69 | 70 | public getPriority() { 71 | return this.priority; 72 | } 73 | } 74 | 75 | export class ExpansionMissionFactory implements MissionFactory { 76 | getName(): string { 77 | return "ExpansionMissionFactory"; 78 | } 79 | 80 | maybeCreateMissions( 81 | gameApi: GameApi, 82 | productionApi: ProductionApi, 83 | playerData: PlayerData, 84 | matchAwareness: MatchAwareness, 85 | missionController: MissionController, 86 | logger: DebugLogger, 87 | ): void { 88 | // At this point, only expand if we have a loose MCV. 89 | const mcvs = gameApi.getVisibleUnits(playerData.name, "self", (r) => 90 | gameApi.getGeneralRules().baseUnit.includes(r.name), 91 | ); 92 | mcvs.forEach((mcv) => { 93 | missionController.addMission(new ExpansionMission("expand-with-" + mcv, 100, mcv, logger)); 94 | }); 95 | } 96 | 97 | onMissionFailed( 98 | gameApi: GameApi, 99 | playerData: PlayerData, 100 | matchAwareness: MatchAwareness, 101 | failedMission: Mission, 102 | failureReason: undefined, 103 | missionController: MissionController, 104 | ): void {} 105 | } 106 | -------------------------------------------------------------------------------- /src/bot/logic/mission/missions/retreatMission.ts: -------------------------------------------------------------------------------- 1 | import { DebugLogger } from "../../common/utils.js"; 2 | import { ActionsApi, GameApi, OrderType, PlayerData, ProductionApi, Vector2 } from "@chronodivide/game-api"; 3 | import { Mission, MissionAction, disbandMission, requestSpecificUnits } from "../mission.js"; 4 | import { ActionBatcher } from "../actionBatcher.js"; 5 | import { MatchAwareness } from "../../awareness.js"; 6 | 7 | export class RetreatMission extends Mission { 8 | private createdAt: number | null = null; 9 | 10 | constructor( 11 | uniqueName: string, 12 | private retreatToPoint: Vector2, 13 | private withUnitIds: number[], 14 | logger: DebugLogger, 15 | ) { 16 | super(uniqueName, logger); 17 | } 18 | 19 | public _onAiUpdate( 20 | gameApi: GameApi, 21 | actionsApi: ActionsApi, 22 | playerData: PlayerData, 23 | matchAwareness: MatchAwareness, 24 | actionBatcher: ActionBatcher, 25 | ): MissionAction { 26 | if (!this.createdAt) { 27 | this.createdAt = gameApi.getCurrentTick(); 28 | } 29 | if (this.getUnitIds().length > 0) { 30 | // Only send the order once we have managed to claim some units. 31 | actionsApi.orderUnits( 32 | this.getUnitIds(), 33 | OrderType.AttackMove, 34 | this.retreatToPoint.x, 35 | this.retreatToPoint.y, 36 | ); 37 | return disbandMission(); 38 | } 39 | if (this.createdAt && gameApi.getCurrentTick() > this.createdAt + 240) { 40 | // Disband automatically after 240 ticks in case we couldn't actually claim any units. 41 | return disbandMission(); 42 | } else { 43 | return requestSpecificUnits(this.withUnitIds, 1000); 44 | } 45 | } 46 | 47 | public getGlobalDebugText(): string | undefined { 48 | return `retreat with ${this.withUnitIds.length} units`; 49 | } 50 | 51 | public getPriority() { 52 | return 100; 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/bot/logic/mission/missions/scoutingMission.ts: -------------------------------------------------------------------------------- 1 | import { ActionsApi, GameApi, OrderType, PlayerData, ProductionApi, Vector2 } from "@chronodivide/game-api"; 2 | import { MissionFactory } from "../missionFactories.js"; 3 | import { MatchAwareness } from "../../awareness.js"; 4 | import { Mission, MissionAction, disbandMission, noop, requestUnits } from "../mission.js"; 5 | import { AttackMission } from "./attackMission.js"; 6 | import { MissionController } from "../missionController.js"; 7 | import { DebugLogger } from "../../common/utils.js"; 8 | import { ActionBatcher } from "../actionBatcher.js"; 9 | import { getDistanceBetweenTileAndPoint } from "../../map/map.js"; 10 | import { PrioritisedScoutTarget } from "../../common/scout.js"; 11 | 12 | const SCOUT_MOVE_COOLDOWN_TICKS = 30; 13 | 14 | // Max units to spend on a particular scout target. 15 | const MAX_ATTEMPTS_PER_TARGET = 5; 16 | 17 | // Maximum ticks to spend trying to scout a target *without making progress towards it*. 18 | // Every time a unit gets closer to the target, the timer refreshes. 19 | const MAX_TICKS_PER_TARGET = 600; 20 | 21 | /** 22 | * A mission that tries to scout around the map with a cheap, fast unit (usually attack dogs) 23 | */ 24 | export class ScoutingMission extends Mission { 25 | private scoutTarget: Vector2 | null = null; 26 | private attemptsOnCurrentTarget: number = 0; 27 | private scoutTargetRefreshedAt: number = 0; 28 | private lastMoveCommandTick: number = 0; 29 | private scoutTargetIsPermanent: boolean = false; 30 | 31 | // Minimum distance from a scout to the target. 32 | private scoutMinDistance?: number; 33 | 34 | private hadUnit: boolean = false; 35 | 36 | constructor( 37 | uniqueName: string, 38 | private priority: number, 39 | logger: DebugLogger, 40 | ) { 41 | super(uniqueName, logger); 42 | } 43 | 44 | public _onAiUpdate( 45 | gameApi: GameApi, 46 | actionsApi: ActionsApi, 47 | playerData: PlayerData, 48 | matchAwareness: MatchAwareness, 49 | actionBatcher: ActionBatcher, 50 | ): MissionAction { 51 | const scoutNames = ["ADOG", "DOG", "E1", "E2", "FV", "HTK"]; 52 | const scouts = this.getUnitsOfTypes(gameApi, ...scoutNames); 53 | 54 | if ((matchAwareness.getSectorCache().getOverallVisibility() || 0) > 0.9) { 55 | return disbandMission(); 56 | } 57 | 58 | if (scouts.length === 0) { 59 | // Count the number of times the scout dies trying to uncover the current scoutTarget. 60 | if (this.scoutTarget && this.hadUnit) { 61 | this.attemptsOnCurrentTarget++; 62 | this.hadUnit = false; 63 | } 64 | return requestUnits(scoutNames, this.priority); 65 | } else if (this.scoutTarget) { 66 | this.hadUnit = true; 67 | if (!this.scoutTargetIsPermanent) { 68 | if (this.attemptsOnCurrentTarget > MAX_ATTEMPTS_PER_TARGET) { 69 | this.logger( 70 | `Scout target ${this.scoutTarget.x},${this.scoutTarget.y} took too many attempts, moving to next`, 71 | ); 72 | this.setScoutTarget(null, 0); 73 | return noop(); 74 | } 75 | if (gameApi.getCurrentTick() > this.scoutTargetRefreshedAt + MAX_TICKS_PER_TARGET) { 76 | this.logger( 77 | `Scout target ${this.scoutTarget.x},${this.scoutTarget.y} took too long, moving to next`, 78 | ); 79 | this.setScoutTarget(null, 0); 80 | return noop(); 81 | } 82 | } 83 | const targetTile = gameApi.mapApi.getTile(this.scoutTarget.x, this.scoutTarget.y); 84 | if (!targetTile) { 85 | throw new Error(`target tile ${this.scoutTarget.x},${this.scoutTarget.y} does not exist`); 86 | } 87 | if (gameApi.getCurrentTick() > this.lastMoveCommandTick + SCOUT_MOVE_COOLDOWN_TICKS) { 88 | this.lastMoveCommandTick = gameApi.getCurrentTick(); 89 | scouts.forEach((unit) => { 90 | if (this.scoutTarget) { 91 | actionsApi.orderUnits([unit.id], OrderType.AttackMove, this.scoutTarget.x, this.scoutTarget.y); 92 | } 93 | }); 94 | // Check that a scout is actually moving closer to the target. 95 | const distances = scouts.map((unit) => getDistanceBetweenTileAndPoint(unit.tile, this.scoutTarget!)); 96 | const newMinDistance = Math.min(...distances); 97 | if (!this.scoutMinDistance || newMinDistance < this.scoutMinDistance) { 98 | this.logger( 99 | `Scout timeout refreshed because unit moved closer to point (${newMinDistance} < ${this.scoutMinDistance})`, 100 | ); 101 | this.scoutTargetRefreshedAt = gameApi.getCurrentTick(); 102 | this.scoutMinDistance = newMinDistance; 103 | } 104 | } 105 | if (gameApi.mapApi.isVisibleTile(targetTile, playerData.name)) { 106 | this.logger( 107 | `Scout target ${this.scoutTarget.x},${this.scoutTarget.y} successfully scouted, moving to next`, 108 | ); 109 | this.setScoutTarget(null, gameApi.getCurrentTick()); 110 | } 111 | } else { 112 | const nextScoutTarget = matchAwareness.getScoutingManager().getNewScoutTarget(); 113 | if (!nextScoutTarget) { 114 | this.logger(`No more scouting targets available, disbanding.`); 115 | return disbandMission(); 116 | } 117 | this.setScoutTarget(nextScoutTarget, gameApi.getCurrentTick()); 118 | } 119 | return noop(); 120 | } 121 | 122 | setScoutTarget(target: PrioritisedScoutTarget | null, currentTick: number) { 123 | this.attemptsOnCurrentTarget = 0; 124 | this.scoutTargetRefreshedAt = currentTick; 125 | this.scoutTarget = target?.asVector2() ?? null; 126 | this.scoutMinDistance = undefined; 127 | this.scoutTargetIsPermanent = target?.isPermanent ?? false; 128 | } 129 | 130 | public getGlobalDebugText(): string | undefined { 131 | return "scouting"; 132 | } 133 | 134 | public getPriority() { 135 | return this.priority; 136 | } 137 | } 138 | 139 | const SCOUT_COOLDOWN_TICKS = 300; 140 | 141 | export class ScoutingMissionFactory implements MissionFactory { 142 | constructor(private lastScoutAt: number = -SCOUT_COOLDOWN_TICKS) {} 143 | 144 | getName(): string { 145 | return "ScoutingMissionFactory"; 146 | } 147 | 148 | maybeCreateMissions( 149 | gameApi: GameApi, 150 | productionApi: ProductionApi, 151 | playerData: PlayerData, 152 | matchAwareness: MatchAwareness, 153 | missionController: MissionController, 154 | logger: DebugLogger, 155 | ): void { 156 | if (gameApi.getCurrentTick() < this.lastScoutAt + SCOUT_COOLDOWN_TICKS) { 157 | return; 158 | } 159 | if (!matchAwareness.getScoutingManager().hasScoutTargets()) { 160 | return; 161 | } 162 | if (!missionController.addMission(new ScoutingMission("globalScout", 10, logger))) { 163 | this.lastScoutAt = gameApi.getCurrentTick(); 164 | } 165 | } 166 | 167 | onMissionFailed( 168 | gameApi: GameApi, 169 | playerData: PlayerData, 170 | matchAwareness: MatchAwareness, 171 | failedMission: Mission, 172 | failureReason: undefined, 173 | missionController: MissionController, 174 | logger: DebugLogger, 175 | ): void { 176 | if (gameApi.getCurrentTick() < this.lastScoutAt + SCOUT_COOLDOWN_TICKS) { 177 | return; 178 | } 179 | if (!matchAwareness.getScoutingManager().hasScoutTargets()) { 180 | return; 181 | } 182 | if (failedMission instanceof AttackMission) { 183 | missionController.addMission(new ScoutingMission("globalScout", 10, logger)); 184 | this.lastScoutAt = gameApi.getCurrentTick(); 185 | } 186 | } 187 | } 188 | -------------------------------------------------------------------------------- /src/bot/logic/mission/missions/squads/combatSquad.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ActionsApi, 3 | AttackState, 4 | GameApi, 5 | GameMath, 6 | MovementZone, 7 | PlayerData, 8 | UnitData, 9 | Vector2, 10 | } from "@chronodivide/game-api"; 11 | import { MatchAwareness } from "../../../awareness.js"; 12 | import { getAttackWeight, manageAttackMicro, manageMoveMicro } from "./common.js"; 13 | import { DebugLogger, isOwnedByNeutral, maxBy, minBy } from "../../../common/utils.js"; 14 | import { ActionBatcher, BatchableAction } from "../../actionBatcher.js"; 15 | import { Squad } from "./squad.js"; 16 | import { Mission, MissionAction, grabCombatants, noop } from "../../mission.js"; 17 | 18 | const TARGET_UPDATE_INTERVAL_TICKS = 10; 19 | 20 | // Units must be in a certain radius of the center of mass before attacking. 21 | // This scales for number of units in the squad though. 22 | const MIN_GATHER_RADIUS = 5; 23 | 24 | // If the radius expands beyond this amount then we should switch back to gathering mode. 25 | const MAX_GATHER_RADIUS = 15; 26 | 27 | const GATHER_RATIO = 10; 28 | 29 | const ATTACK_SCAN_AREA = 15; 30 | 31 | enum SquadState { 32 | Gathering, 33 | Attacking, 34 | } 35 | 36 | export class CombatSquad implements Squad { 37 | private lastCommand: number | null = null; 38 | private state = SquadState.Gathering; 39 | 40 | private debugLastTarget: string | undefined; 41 | 42 | private lastOrderGiven: { [unitId: number]: BatchableAction } = {}; 43 | 44 | /** 45 | * 46 | * @param rallyArea the initial location to grab combatants 47 | * @param targetArea 48 | * @param radius 49 | */ 50 | constructor( 51 | private rallyArea: Vector2, 52 | private targetArea: Vector2, 53 | private radius: number, 54 | ) {} 55 | 56 | public getGlobalDebugText(): string | undefined { 57 | return this.debugLastTarget ?? ""; 58 | } 59 | 60 | public setAttackArea(targetArea: Vector2) { 61 | this.targetArea = targetArea; 62 | } 63 | 64 | public onAiUpdate( 65 | gameApi: GameApi, 66 | actionsApi: ActionsApi, 67 | actionBatcher: ActionBatcher, 68 | playerData: PlayerData, 69 | mission: Mission, 70 | matchAwareness: MatchAwareness, 71 | logger: DebugLogger, 72 | ): MissionAction { 73 | if ( 74 | mission.getUnitIds().length > 0 && 75 | (!this.lastCommand || gameApi.getCurrentTick() > this.lastCommand + TARGET_UPDATE_INTERVAL_TICKS) 76 | ) { 77 | this.lastCommand = gameApi.getCurrentTick(); 78 | const centerOfMass = mission.getCenterOfMass(); 79 | const maxDistance = mission.getMaxDistanceToCenterOfMass(); 80 | const unitIds = mission.getUnitsMatchingByRule(gameApi, (r) => r.isSelectableCombatant); 81 | const units = unitIds 82 | .map((unitId) => gameApi.getUnitData(unitId)) 83 | .filter((unit): unit is UnitData => !!unit); 84 | 85 | // Only use ground units for center of mass. 86 | const groundUnitIds = mission.getUnitsMatchingByRule( 87 | gameApi, 88 | (r) => 89 | r.isSelectableCombatant && 90 | (r.movementZone === MovementZone.Infantry || 91 | r.movementZone === MovementZone.Normal || 92 | r.movementZone === MovementZone.InfantryDestroyer), 93 | ); 94 | 95 | if (this.state === SquadState.Gathering) { 96 | const requiredGatherRadius = GameMath.sqrt(groundUnitIds.length) * GATHER_RATIO + MIN_GATHER_RADIUS; 97 | if ( 98 | centerOfMass && 99 | maxDistance && 100 | gameApi.mapApi.getTile(centerOfMass.x, centerOfMass.y) !== undefined && 101 | maxDistance > requiredGatherRadius 102 | ) { 103 | units.forEach((unit) => { 104 | const moveAction = manageMoveMicro(unit, centerOfMass); 105 | if (moveAction) { 106 | this.submitActionIfNew(actionBatcher, moveAction); 107 | } 108 | }); 109 | } else { 110 | logger(`CombatSquad ${mission.getUniqueName()} switching back to attack mode (${maxDistance})`); 111 | this.state = SquadState.Attacking; 112 | } 113 | } else { 114 | const targetPoint = this.targetArea || playerData.startLocation; 115 | const requiredGatherRadius = GameMath.sqrt(groundUnitIds.length) * GATHER_RATIO + MAX_GATHER_RADIUS; 116 | if ( 117 | centerOfMass && 118 | maxDistance && 119 | gameApi.mapApi.getTile(centerOfMass.x, centerOfMass.y) !== undefined && 120 | maxDistance > requiredGatherRadius 121 | ) { 122 | // Switch back to gather mode 123 | logger(`CombatSquad ${mission.getUniqueName()} switching back to gather (${maxDistance})`); 124 | this.state = SquadState.Gathering; 125 | return noop(); 126 | } 127 | // The unit with the shortest range chooses the target. Otherwise, a base range of 5 is chosen. 128 | const getRangeForUnit = (unit: UnitData) => 129 | unit.primaryWeapon?.maxRange ?? unit.secondaryWeapon?.maxRange ?? 5; 130 | const attackLeader = minBy(units, getRangeForUnit); 131 | if (!attackLeader) { 132 | return noop(); 133 | } 134 | // Find units within double the range of the leader. 135 | const nearbyHostiles = matchAwareness 136 | .getHostilesNearPoint(attackLeader.tile.rx, attackLeader.tile.ry, ATTACK_SCAN_AREA) 137 | .map(({ unitId }) => gameApi.getUnitData(unitId)) 138 | .filter((unit) => !isOwnedByNeutral(unit)) as UnitData[]; 139 | 140 | for (const unit of units) { 141 | const bestUnit = maxBy(nearbyHostiles, (target) => getAttackWeight(unit, target)); 142 | if (bestUnit) { 143 | const attackAction = manageAttackMicro(unit, bestUnit); 144 | if (attackAction) { 145 | this.submitActionIfNew(actionBatcher, attackAction); 146 | } 147 | this.debugLastTarget = `Unit ${bestUnit.id.toString()}`; 148 | } else { 149 | const moveAction = manageMoveMicro(unit, targetPoint); 150 | if (moveAction) { 151 | this.submitActionIfNew(actionBatcher, moveAction); 152 | } 153 | this.debugLastTarget = `@${targetPoint.x},${targetPoint.y}`; 154 | } 155 | } 156 | } 157 | } 158 | return noop(); 159 | } 160 | 161 | /** 162 | * Sends an action to the acitonBatcher if and only if the action is different from the last action we submitted to it. 163 | * Prevents spamming redundant orders, which affects performance and can also ccause the unit to sit around doing nothing. 164 | */ 165 | private submitActionIfNew(actionBatcher: ActionBatcher, action: BatchableAction) { 166 | const lastAction = this.lastOrderGiven[action.unitId]; 167 | if (!lastAction || !lastAction.isSameAs(action)) { 168 | actionBatcher.push(action); 169 | this.lastOrderGiven[action.unitId] = action; 170 | } 171 | } 172 | } 173 | -------------------------------------------------------------------------------- /src/bot/logic/mission/missions/squads/common.ts: -------------------------------------------------------------------------------- 1 | import { AttackState, ObjectType, OrderType, StanceType, UnitData, Vector2, ZoneType } from "@chronodivide/game-api"; 2 | import { getDistanceBetweenPoints, getDistanceBetweenUnits } from "../../../map/map.js"; 3 | import { BatchableAction } from "../../actionBatcher.js"; 4 | 5 | const NONCE_GI_DEPLOY = 0; 6 | const NONCE_GI_UNDEPLOY = 1; 7 | 8 | // Micro methods 9 | export function manageMoveMicro(attacker: UnitData, attackPoint: Vector2): BatchableAction | null { 10 | if (attacker.ammo === 0) { 11 | return null; 12 | } 13 | 14 | if (attacker.name === "E1") { 15 | const isDeployed = attacker.stance === StanceType.Deployed; 16 | if (isDeployed) { 17 | return BatchableAction.noTarget(attacker.id, OrderType.DeploySelected, NONCE_GI_UNDEPLOY); 18 | } 19 | } 20 | 21 | return BatchableAction.toPoint(attacker.id, OrderType.AttackMove, attackPoint); 22 | } 23 | 24 | export function manageAttackMicro(attacker: UnitData, target: UnitData): BatchableAction | null { 25 | if (attacker.ammo === 0) { 26 | return null; 27 | } 28 | 29 | const distance = getDistanceBetweenUnits(attacker, target); 30 | if (attacker.name === "E1") { 31 | // Para (deployed weapon) range is 5. 32 | const deployedWeaponRange = attacker.secondaryWeapon?.maxRange || 5; 33 | const isDeployed = attacker.stance === StanceType.Deployed; 34 | if (!isDeployed && (distance <= deployedWeaponRange || attacker.attackState === AttackState.JustFired)) { 35 | return BatchableAction.noTarget(attacker.id, OrderType.DeploySelected, NONCE_GI_DEPLOY); 36 | } else if (isDeployed && distance > deployedWeaponRange) { 37 | return BatchableAction.noTarget(attacker.id, OrderType.DeploySelected, NONCE_GI_UNDEPLOY); 38 | } 39 | } 40 | let targetData = target; 41 | let orderType: OrderType = OrderType.Attack; 42 | const primaryWeaponRange = attacker.primaryWeapon?.maxRange || 5; 43 | if (targetData?.type == ObjectType.Building && distance < primaryWeaponRange * 0.8) { 44 | orderType = OrderType.Attack; 45 | } else if (targetData?.rules.canDisguise) { 46 | // Special case for mirage tank/spy as otherwise they just sit next to it. 47 | orderType = OrderType.ForceAttack; 48 | } 49 | return BatchableAction.toTargetId(attacker.id, orderType, target.id); 50 | } 51 | 52 | /** 53 | * 54 | * @param attacker 55 | * @param target 56 | * @returns A number describing the weight of the given target for the attacker, or null if it should not attack it. 57 | */ 58 | export function getAttackWeight(attacker: UnitData, target: UnitData): number | null { 59 | const { rx: x, ry: y } = attacker.tile; 60 | const { rx: hX, ry: hY } = target.tile; 61 | 62 | if (!attacker.primaryWeapon?.projectileRules.isAntiAir && target.zone === ZoneType.Air) { 63 | return null; 64 | } 65 | 66 | if (!attacker.primaryWeapon?.projectileRules.isAntiGround && target.zone === ZoneType.Ground) { 67 | return null; 68 | } 69 | 70 | return 1000000 - getDistanceBetweenPoints(new Vector2(x, y), new Vector2(hX, hY)); 71 | } 72 | -------------------------------------------------------------------------------- /src/bot/logic/mission/missions/squads/squad.ts: -------------------------------------------------------------------------------- 1 | import { ActionsApi, GameApi, PlayerData } from "@chronodivide/game-api"; 2 | import { ActionBatcher } from "../../actionBatcher"; 3 | import { Mission, MissionAction } from "../../mission"; 4 | import { MatchAwareness } from "../../../awareness"; 5 | import { DebugLogger } from "../../../common/utils"; 6 | 7 | export interface Squad { 8 | onAiUpdate( 9 | gameApi: GameApi, 10 | actionsApi: ActionsApi, 11 | actionBatcher: ActionBatcher, 12 | playerData: PlayerData, 13 | mission: Mission, 14 | matchAwareness: MatchAwareness, 15 | logger: DebugLogger, 16 | ): MissionAction; 17 | 18 | getGlobalDebugText(): string | undefined; 19 | } 20 | -------------------------------------------------------------------------------- /src/bot/logic/mission/missions/triggers/aiTaskForces.ts: -------------------------------------------------------------------------------- 1 | // Interpreter for ai.ini TriggerTypes 2 | 3 | import { IniFile, IniSection } from "@chronodivide/game-api"; 4 | 5 | export const loadTaskForces = (aiIni: IniFile): { [id: string]: AiTaskForce } => { 6 | const taskForcesIndex = aiIni.getSection("TaskForces"); 7 | if (!taskForcesIndex) { 8 | throw new Error("Missing TaskForces in ai.ini"); 9 | } 10 | const taskForces: { [id: string]: AiTaskForce } = {}; 11 | 12 | taskForcesIndex.entries.forEach((taskForceId, key) => { 13 | const section = aiIni.getSection(taskForceId); 14 | if (!section) { 15 | throw new Error(`Missing TaskForce ${taskForceId} in ai.ini`); 16 | } 17 | taskForces[taskForceId] = new AiTaskForce(taskForceId, section); 18 | }); 19 | 20 | return taskForces; 21 | }; 22 | 23 | const MAX_TASK_FORCE_SLOT = 50; 24 | 25 | // https://modenc.renegadeprojects.com/TaskForces 26 | export class AiTaskForce { 27 | public readonly name: string; 28 | public readonly units: { [unitName: string]: number } = {}; 29 | 30 | constructor( 31 | public readonly id: string, 32 | iniSection: IniSection, 33 | ) { 34 | // it is assumed that iniSection is genuinely a TeamType, and not some other key. 35 | this.name = iniSection.getString("Name"); 36 | for (let i = 0; i < MAX_TASK_FORCE_SLOT; ++i) { 37 | if (!iniSection.has(i.toString())) { 38 | break; 39 | } 40 | const text = iniSection.getString(i.toString()); 41 | const [countStr, unitName] = text.split(","); 42 | this.units[unitName] = parseInt(countStr); 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/bot/logic/mission/missions/triggers/aiTeamTypes.ts: -------------------------------------------------------------------------------- 1 | // Interpreter for ai.ini TriggerTypes 2 | 3 | import { IniFile, IniSection } from "@chronodivide/game-api"; 4 | import { Countries } from "../../../common/utils.js"; 5 | 6 | export const loadTeamTypes = (aiIni: IniFile): { [id: string]: AiTeamType } => { 7 | const teamTypeIndex = aiIni.getSection("TeamTypes"); 8 | if (!teamTypeIndex) { 9 | throw new Error("Missing TeamTypes in ai.ini"); 10 | } 11 | 12 | const teamTypes: { [id: string]: AiTeamType } = {}; 13 | 14 | teamTypeIndex.entries.forEach((teamTypeId, key) => { 15 | const section = aiIni.getSection(teamTypeId); 16 | if (!section) { 17 | throw new Error(`Missing TeamType ${teamTypeId} in ai.ini`); 18 | } 19 | teamTypes[teamTypeId] = new AiTeamType(teamTypeId, section); 20 | }); 21 | 22 | return teamTypes; 23 | }; 24 | 25 | // https://modenc.renegadeprojects.com/Category:TeamTypes_Flags 26 | export class AiTeamType { 27 | // This is not a full set of flags for a team type. Only those that are likely to be useful for skirmish behaviour are included. 28 | public readonly name: string; 29 | public readonly annoyance: boolean; 30 | public readonly areTeamMembersRecruitable: boolean; 31 | public readonly autocreate: boolean; 32 | public readonly avoidThreats: boolean; 33 | public readonly guardSlower: boolean; 34 | public readonly isBaseDefense: boolean; 35 | public readonly priority: number; 36 | public readonly max: number; 37 | public readonly reinforce: boolean; 38 | public readonly script: string; 39 | public readonly taskForce: string; 40 | public readonly whiner: boolean; 41 | 42 | constructor( 43 | public readonly id: string, 44 | iniSection: IniSection, 45 | ) { 46 | // it is assumed that iniSection is genuinely a TeamType, and not some other key. 47 | this.name = iniSection.getString("Name"); 48 | this.annoyance = iniSection.getBool("Annoyance"); 49 | this.areTeamMembersRecruitable = iniSection.getBool("AreTeamMembersRecruitable"); 50 | this.autocreate = iniSection.getBool("Autocreate"); 51 | this.avoidThreats = iniSection.getBool("AvoidThreats"); 52 | this.guardSlower = iniSection.getBool("GuardSlower"); 53 | this.isBaseDefense = iniSection.getBool("IsBaseDefense"); 54 | this.priority = iniSection.getNumber("Priority"); 55 | this.max = iniSection.getNumber("Max"); 56 | this.reinforce = iniSection.getBool("Reinforce"); 57 | this.script = iniSection.getString("Script"); 58 | this.taskForce = iniSection.getString("TaskForce"); 59 | this.whiner = iniSection.getBool("Whiner"); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/bot/logic/mission/missions/triggers/aiTriggerTypes.ts: -------------------------------------------------------------------------------- 1 | // Interpreter for ai.ini TriggerTypes 2 | 3 | import { Countries } from "../../../common/utils.js"; 4 | 5 | export enum AiTriggerOwnerHouse { 6 | None = "", 7 | All = "", 8 | } 9 | 10 | export enum AiTriggerSideType { 11 | All = 0, 12 | Allied = 1, 13 | Soviet = 2, 14 | } 15 | 16 | export enum ConditionType { 17 | AlwaysTrue = -1, 18 | EnemyHouseOwns = 0, 19 | OwningHouseOwns = 1, 20 | EnemyHouseInYellowPower = 2, 21 | EnemyHouseInRedPower = 3, 22 | EnemyHouseHasCredits = 4, // cannot implement 23 | OwnerHasIronCurtainReady = 5, // see [General].AIMinorSuperReadyPercent 24 | OwnerHasChronoSphereReady = 6, 25 | NeutralHouseOwns = 7, 26 | } 27 | 28 | const conditionEvaluators: Map = new Map([ 29 | [ConditionType.AlwaysTrue, "Always True"], 30 | [ConditionType.EnemyHouseOwns, "Enemy House Owns"], 31 | [ConditionType.OwningHouseOwns, "Owning House Owns"], 32 | [ConditionType.EnemyHouseInYellowPower, "Enemy House In Yellow Power"], 33 | [ConditionType.EnemyHouseInRedPower, "Enemy House In Red Power"], 34 | [ConditionType.EnemyHouseHasCredits, "Enemy House Has Credits"], 35 | [ConditionType.OwnerHasIronCurtainReady, "Owner Has Iron Curtain Ready"], 36 | [ConditionType.OwnerHasChronoSphereReady, "Owner Has Chronosphere Ready"], 37 | [ConditionType.NeutralHouseOwns, "Neutral House Owns"], 38 | ]); 39 | 40 | export enum ComparatorOperator { 41 | LessThan = 0, 42 | LessThanOrEqual = 1, 43 | Equal = 2, 44 | GreaterThanOrEqual = 3, 45 | GreaterThan = 4, 46 | NotEqual = 5, 47 | } 48 | 49 | const comparatorOperators: Map = new Map([ 50 | [ComparatorOperator.LessThan, "<"], 51 | [ComparatorOperator.LessThanOrEqual, "<="], 52 | [ComparatorOperator.Equal, "=="], 53 | [ComparatorOperator.GreaterThanOrEqual, ">="], 54 | [ComparatorOperator.GreaterThan, ">"], 55 | [ComparatorOperator.NotEqual, "!="], 56 | ]); 57 | 58 | // https://modenc.renegadeprojects.com/AITriggerTypes 59 | export class AiTriggerType { 60 | public readonly name: string; 61 | public readonly teamType: string; 62 | public readonly ownerHouse: AiTriggerOwnerHouse | Countries; 63 | public readonly techLevel: number; // Not implemented 64 | public readonly conditionType: ConditionType; 65 | public readonly comparisonObject: string; 66 | public readonly comparator: string; 67 | public readonly startingWeight: number; 68 | public readonly minimumWeight: number; 69 | public readonly maximumWeight: number; 70 | public readonly isForSkirmish: boolean; // Not implemented, assumed true 71 | public readonly side: AiTriggerSideType; 72 | public readonly isBaseDefence: boolean; 73 | public readonly otherTeamType: string; 74 | public readonly enabledInEasy: boolean; 75 | public readonly enabledInMedium: boolean; 76 | public readonly enabledInHard: boolean; 77 | 78 | public readonly comparatorArgument: number; 79 | public readonly comparatorOperator: ComparatorOperator; 80 | 81 | private readonly descriptionText: string; 82 | 83 | constructor( 84 | public readonly id: string, 85 | value: string, 86 | ) { 87 | const values = value.split(","); 88 | this.name = values[0]; 89 | this.teamType = values[1]; 90 | this.ownerHouse = this.parseOwnerHouse(values[2]); 91 | this.techLevel = parseInt(values[3]); 92 | this.conditionType = parseInt(values[4]); 93 | this.comparisonObject = values[5]; 94 | this.comparator = values[6]; 95 | this.startingWeight = parseFloat(values[7]); 96 | this.minimumWeight = parseFloat(values[8]); 97 | this.maximumWeight = parseFloat(values[9]); 98 | this.isForSkirmish = this.parseBoolean(values[10]); 99 | // 11 is unused 100 | this.side = parseInt(values[12]); 101 | this.isBaseDefence = this.parseBoolean(values[13]); 102 | this.otherTeamType = values[14]; 103 | this.enabledInEasy = this.parseBoolean(values[15]); 104 | this.enabledInMedium = this.parseBoolean(values[16]); 105 | this.enabledInHard = this.parseBoolean(values[17]); 106 | 107 | this.comparatorArgument = this.parseLittleEndianHex(this.comparator.slice(0, 8)); 108 | this.comparatorOperator = this.parseLittleEndianHex(this.comparator.slice(8, 16)); 109 | 110 | this.descriptionText = this.describeComparator(); 111 | } 112 | 113 | private describeComparator() { 114 | const conditionName = conditionEvaluators.get(this.conditionType) ?? `Unknown Condition ${this.conditionType}`; 115 | const comparatorOperatorText = 116 | comparatorOperators.get(this.comparatorOperator) ?? `Unknown Operator ${this.comparatorOperator}`; 117 | const comparatorArgument = this.comparatorArgument; 118 | return `${conditionName} ${this.comparisonObject} ${comparatorOperatorText} ${comparatorArgument}`; 119 | } 120 | 121 | public toString() { 122 | return `${this.descriptionText}: ${this.name}`; 123 | } 124 | 125 | /** 126 | * 127 | * @param val string containing an octet of hexadecimal characters 128 | */ 129 | private parseLittleEndianHex(val: string): number { 130 | if (val.length !== 8) { 131 | throw new Error(`Expected hex string of length 8, got: ${val}`); 132 | } 133 | // the comparator consists of octets without spaces in little-endian hex form (so 04000000 = 04 00 .. = 4) 134 | let str = ""; 135 | for (let i = 0; i < 8; i += 2) { 136 | str = val.slice(i, i + 2) + str; 137 | } 138 | return parseInt(str, 16); 139 | } 140 | 141 | private parseOwnerHouse(val: string): AiTriggerOwnerHouse | Countries { 142 | if (val === AiTriggerOwnerHouse.None) { 143 | return AiTriggerOwnerHouse.None; 144 | } else if (val === AiTriggerOwnerHouse.All) { 145 | return AiTriggerOwnerHouse.All; 146 | } else if (Object.values(Countries).includes(val)) { 147 | return val as Countries; 148 | } else { 149 | throw Error(`invalid OwnerHouse ${val}`); 150 | } 151 | } 152 | 153 | private parseBoolean(val: string): boolean { 154 | return val !== "0"; 155 | } 156 | } 157 | -------------------------------------------------------------------------------- /src/bot/logic/mission/missions/triggers/scriptTypes.ts: -------------------------------------------------------------------------------- 1 | // Interpreter for ai.ini TriggerTypes 2 | 3 | import { IniFile, IniSection } from "@chronodivide/game-api"; 4 | 5 | export const loadScriptTypes = (aiIni: IniFile): { [id: string]: AiScriptType } => { 6 | const scriptTypesIndex = aiIni.getSection("ScriptTypes"); 7 | if (!scriptTypesIndex) { 8 | throw new Error("Missing ScriptTypes in ai.ini"); 9 | } 10 | const scriptTypes: { [id: string]: AiScriptType } = {}; 11 | 12 | scriptTypesIndex.entries.forEach((scriptTypeId, key) => { 13 | const section = aiIni.getSection(scriptTypeId); 14 | if (!section) { 15 | throw new Error(`Missing ScriptType ${scriptTypeId} in ai.ini`); 16 | } 17 | scriptTypes[scriptTypeId] = new AiScriptType(scriptTypeId, section); 18 | }); 19 | 20 | return scriptTypes; 21 | }; 22 | 23 | const MAX_SCRIPT_TYPE_COUNT = 50; 24 | 25 | export enum ScriptTypeAction { 26 | AttackQuarryType = 0, 27 | AttackWaypoint = 1, 28 | DoNothing = 2, 29 | MoveToWaypoint = 3, 30 | MoveIntoSpecificCell = 4, 31 | GuardArea = 5, 32 | JumpToLine = 6, 33 | ForcePlayerWin = 7, 34 | Unload = 8, 35 | Deploy = 9, 36 | FollowFriendlies = 10, 37 | AssignNewMission = 11, 38 | SetGlobalVariable = 12, 39 | PlayIdleAnimSequence = 13, 40 | LoadOntoTransport = 14, 41 | SpyOnStructureAtWaypoint = 15, 42 | PatrolToWaypoint = 16, 43 | ChangeScript = 17, 44 | ChangeTeam = 18, 45 | Panic = 19, 46 | ChangeHouseOwnership = 20, 47 | Scatter = 21, 48 | AfraidAndRunToShroud = 22, 49 | ForcePlayerLoss = 23, 50 | PlaySpeech = 24, 51 | PlaySound = 25, 52 | PlayMovie = 26, 53 | PlayTheme = 27, 54 | ReduceTiberiumOre = 28, 55 | BeginProduction = 29, 56 | ForceSale = 30, 57 | Suicide = 31, 58 | StartStormIn = 32, 59 | EndStorm = 33, 60 | CenterMapOnTeam = 34, 61 | ShroudMapForTimeInterval = 35, 62 | RevealMapForTimeInterval = 36, 63 | DeleteTeamMembers = 37, 64 | ClearGlobalVariable = 38, 65 | SetLocalVariable = 39, 66 | ClearLocalVariable = 40, 67 | Unpanic = 41, 68 | ChangeFacing = 42, 69 | WaitUntilFullyLoaded = 43, 70 | UnloadTruck = 44, 71 | LoadTruck = 45, 72 | AttackEnemyStructure = 46, 73 | MoveToEnemyStructure = 47, 74 | Scout = 48, 75 | RegisterSuccess = 49, 76 | Flash = 50, 77 | PlayAnimation = 51, 78 | DisplayTalkBubble = 52, 79 | GatherAtEnemyBase = 53, 80 | RegroupAtFriendlyBase = 54, 81 | ActivateIronCurtainOnTaskForce = 55, 82 | ChronoshiftTaskForceToBuilding = 56, 83 | ChronoshiftTaskForceToTargetType = 57, 84 | MoveToFriendlyStructure = 58, 85 | AttackStructureAtWaypoint = 59, 86 | EnterGrinder = 60, 87 | OccupyTankBunker = 61, 88 | EnterBioReactor = 62, 89 | OccupyBattleBunker = 63, 90 | GarrisonStructure = 64, 91 | } 92 | 93 | /** 94 | * Which scripts are actually used in the default ini? 95 | * 58 38 -> MoveToFriendlyStructure 96 | 5 16 -> GuardArea 97 | 6 3 -> JumpToLine 98 | 11 15 -> AssignNewMission 99 | 54 41 -> RegroupAtFriendlyBase 100 | 53 42 -> GatherAtEnemyBase 101 | 0 219 -> AttackQuarryType 102 | 49 65 -> RegisterSuccess 103 | 46 35 -> AttackEnemyStructure 104 | 47 27 -> MoveToEnemyStructure 105 | 14 13 -> LoadOntoTransport 106 | 43 13 -> WaitUntilFullyLoaded 107 | 8 12 -> Unload 108 | 57 2 -> ChronoshiftTaskForceToTargetType 109 | 55 7 -> ActivateIronCurtainOnTaskForce 110 | 21 1 -> Scatter 111 | */ 112 | 113 | export type ScriptTypeEntry = { 114 | action: ScriptTypeAction; 115 | argument: number; 116 | }; 117 | 118 | // https://modenc.renegadeprojects.com/TaskForces 119 | export class AiScriptType { 120 | public readonly name: string; 121 | public readonly actions: ScriptTypeEntry[] = []; 122 | 123 | constructor( 124 | public readonly id: string, 125 | iniSection: IniSection, 126 | ) { 127 | // it is assumed that iniSection is genuinely a TeamType, and not some other key. 128 | this.name = iniSection.getString("Name"); 129 | for (let i = 0; i < MAX_SCRIPT_TYPE_COUNT; ++i) { 130 | if (!iniSection.has(i.toString())) { 131 | break; 132 | } 133 | const text = iniSection.getString(i.toString()); 134 | const [action, argument] = text.split(","); 135 | this.actions.push({ action: parseInt(action), argument: parseInt(argument) }); 136 | } 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /src/bot/logic/threat/threat.ts: -------------------------------------------------------------------------------- 1 | // A periodically-refreshed cache of known threats to a bot so we can use it in decision making. 2 | 3 | export class GlobalThreat { 4 | constructor( 5 | public certainty: number, // 0.0 - 1.0 based on approximate visibility around the map. 6 | public totalOffensiveLandThreat: number, // a number that approximates how much land-based firepower our opponents have. 7 | public totalOffensiveAirThreat: number, // a number that approximates how much airborne firepower our opponents have. 8 | public totalOffensiveAntiAirThreat: number, // a number that approximates how much anti-air firepower our opponents have. 9 | public totalDefensiveThreat: number, // a number that approximates how much defensive power our opponents have. 10 | public totalDefensivePower: number, // a number that approximates how much defensive power we have. 11 | public totalAvailableAntiGroundFirepower: number, // how much anti-ground power we have 12 | public totalAvailableAntiAirFirepower: number, // how much anti-air power we have 13 | public totalAvailableAirPower: number, // how much firepower we have in air units 14 | ) {} 15 | } 16 | -------------------------------------------------------------------------------- /src/bot/logic/threat/threatCalculator.ts: -------------------------------------------------------------------------------- 1 | import { 2 | GameApi, 3 | GameMath, 4 | GameObjectData, 5 | MovementZone, 6 | ObjectType, 7 | PlayerData, 8 | ProjectileRules, 9 | UnitData, 10 | WeaponRules, 11 | } from "@chronodivide/game-api"; 12 | import { GlobalThreat } from "./threat.js"; 13 | import { getCachedTechnoRules } from "../common/rulesCache.js"; 14 | 15 | export function calculateGlobalThreat(game: GameApi, playerData: PlayerData, visibleAreaPercent: number): GlobalThreat { 16 | let groundUnits = game.getVisibleUnits( 17 | playerData.name, 18 | "enemy", 19 | (r) => r.type == ObjectType.Vehicle || r.type == ObjectType.Infantry, 20 | ); 21 | let airUnits = game.getVisibleUnits(playerData.name, "enemy", (r) => r.movementZone == MovementZone.Fly); 22 | let groundDefence = game 23 | .getVisibleUnits(playerData.name, "enemy", (r) => r.type == ObjectType.Building) 24 | .filter((unitId) => isAntiGround(game, unitId)); 25 | let antiAirPower = game 26 | .getVisibleUnits(playerData.name, "enemy", (r) => r.type != ObjectType.Building) 27 | .filter((unitId) => isAntiAir(game, unitId)); 28 | 29 | let ourAntiGroundUnits = game 30 | .getVisibleUnits(playerData.name, "self", (r) => r.isSelectableCombatant) 31 | .filter((unitId) => isAntiGround(game, unitId)); 32 | let ourAntiAirUnits = game 33 | .getVisibleUnits(playerData.name, "self", (r) => r.isSelectableCombatant || r.type === ObjectType.Building) 34 | .filter((unitId) => isAntiAir(game, unitId)); 35 | let ourGroundDefence = game 36 | .getVisibleUnits(playerData.name, "self", (r) => r.type === ObjectType.Building) 37 | .filter((unitId) => isAntiGround(game, unitId)); 38 | let ourAirUnits = game.getVisibleUnits( 39 | playerData.name, 40 | "self", 41 | (r) => r.movementZone == MovementZone.Fly && r.isSelectableCombatant, 42 | ); 43 | 44 | let observedGroundThreat = calculateFirepowerForUnits(game, groundUnits); 45 | let observedAirThreat = calculateFirepowerForUnits(game, airUnits); 46 | let observedAntiAirThreat = calculateFirepowerForUnits(game, antiAirPower); 47 | let observedGroundDefence = calculateFirepowerForUnits(game, groundDefence); 48 | 49 | let ourAntiGroundPower = calculateFirepowerForUnits(game, ourAntiGroundUnits); 50 | let ourAntiAirPower = calculateFirepowerForUnits(game, ourAntiAirUnits); 51 | let ourAirPower = calculateFirepowerForUnits(game, ourAirUnits); 52 | let ourGroundDefencePower = calculateFirepowerForUnits(game, ourGroundDefence); 53 | 54 | return new GlobalThreat( 55 | visibleAreaPercent, 56 | observedGroundThreat, 57 | observedAirThreat, 58 | observedAntiAirThreat, 59 | observedGroundDefence, 60 | ourGroundDefencePower, 61 | ourAntiGroundPower, 62 | ourAntiAirPower, 63 | ourAirPower, 64 | ); 65 | } 66 | 67 | // For the purposes of determining if units can target air/ground, we look purely at the technorules and only the base weapon (not elite) 68 | // This excludes some special cases such as IFVs changing turrets, but we have to deal with it for now. 69 | function isAntiGround(gameApi: GameApi, unitId: number): boolean { 70 | return testProjectile(gameApi, unitId, (p) => p.isAntiGround); 71 | } 72 | function isAntiAir(gameApi: GameApi, unitId: number): boolean { 73 | return testProjectile(gameApi, unitId, (p) => p.isAntiAir); 74 | } 75 | 76 | function testProjectile(gameApi: GameApi, unitId: number, test: (p: ProjectileRules) => boolean) { 77 | const rules = getCachedTechnoRules(gameApi, unitId); 78 | if (!rules || !(rules.primary || rules.secondary)) { 79 | return false; 80 | } 81 | 82 | const primaryWeapon = rules.primary ? gameApi.rulesApi.getWeapon(rules.primary) : null; 83 | const primaryProjectile = getProjectileRules(gameApi, primaryWeapon); 84 | if (primaryProjectile && test(primaryProjectile)) { 85 | return true; 86 | } 87 | 88 | const secondaryWeapon = rules.secondary ? gameApi.rulesApi.getWeapon(rules.secondary) : null; 89 | const secondaryProjectile = getProjectileRules(gameApi, secondaryWeapon); 90 | if (secondaryProjectile && test(secondaryProjectile)) { 91 | return true; 92 | } 93 | 94 | return false; 95 | } 96 | 97 | function getProjectileRules(gameApi: GameApi, weapon: WeaponRules | null): ProjectileRules | null { 98 | const primaryProjectile = weapon ? gameApi.rulesApi.getProjectile(weapon.projectile) : null; 99 | return primaryProjectile; 100 | } 101 | 102 | function calculateFirepowerForUnit(gameApi: GameApi, gameObjectData: GameObjectData): number { 103 | const rules = getCachedTechnoRules(gameApi, gameObjectData.id); 104 | if (!rules) { 105 | return 0; 106 | } 107 | const currentHp = gameObjectData?.hitPoints || 0; 108 | const maxHp = gameObjectData?.maxHitPoints || 0; 109 | let threat = 0; 110 | const hpRatio = currentHp / Math.max(1, maxHp); 111 | 112 | if (rules.primary) { 113 | const weapon = gameApi.rulesApi.getWeapon(rules.primary); 114 | threat += (hpRatio * ((weapon.damage + 1) * GameMath.sqrt(weapon.range + 1))) / Math.max(weapon.rof, 1); 115 | } 116 | if (rules.secondary) { 117 | const weapon = gameApi.rulesApi.getWeapon(rules.secondary); 118 | threat += (hpRatio * ((weapon.damage + 1) * GameMath.sqrt(weapon.range + 1))) / Math.max(weapon.rof, 1); 119 | } 120 | return Math.min(800, threat); 121 | } 122 | 123 | function calculateFirepowerForUnits(game: GameApi, unitIds: number[]) { 124 | let threat = 0; 125 | unitIds.forEach((unitId) => { 126 | const gameObjectData = game.getGameObjectData(unitId); 127 | if (gameObjectData) { 128 | threat += calculateFirepowerForUnit(game, gameObjectData); 129 | } 130 | }); 131 | return threat; 132 | } 133 | -------------------------------------------------------------------------------- /src/dummyBot/dummyBot.ts: -------------------------------------------------------------------------------- 1 | import { Bot } from "@chronodivide/game-api"; 2 | import { Countries } from "../bot/logic/common/utils.js"; 3 | 4 | /* An empty bot implementation for performance testing */ 5 | export class DummyBot extends Bot { 6 | constructor(name: string, country: Countries) { 7 | super(name, country); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import "dotenv/config"; 2 | import { Agent, Bot, CreateBaseOpts, CreateOfflineOpts, CreateOnlineOpts, cdapi } from "@chronodivide/game-api"; 3 | import { BotDifficulty, SupalosaBot } from "./bot/bot.js"; 4 | import { DummyBot } from "./dummyBot/dummyBot.js"; 5 | import { Countries } from "./bot/logic/common/utils.js"; 6 | 7 | // The game will automatically end after this time. This is to handle stalemates. 8 | const MAX_GAME_LENGTH_SECONDS: number | null = 7200; // 7200 = two hours 9 | 10 | async function main() { 11 | /* 12 | Ladder maps: 13 | CDR2 1v1 2_malibu_cliffs_le.map 14 | CDR2 1v1 4_country_swing_le_v2.map 15 | CDR2 1v1 mp01t4.map, large map, oil derricks 16 | CDR2 1v1 tn04t2.map, small map 17 | CDR2 1v1 mp10s4.map <- depth charge, naval map (not supported). Cramped in position 1. 18 | CDR2 1v1 heckcorners.map 19 | CDR2 1v1 4_montana_dmz_le.map 20 | CDR2 1v1 barrel.map 21 | 22 | Other maps: 23 | mp03t4 large map, no oil derricks 24 | mp02t2.map,mp06t2.map,mp11t2.map,mp08t2.map,mp21s2.map,mp14t2.map,mp29u2.map,mp31s2.map,mp18s3.map,mp09t3.map,mp01t4.map,mp03t4.map,mp05t4.map,mp10s4.map,mp12s4.map,mp13s4.map,mp19t4.map, 25 | mp15s4.map,mp16s4.map,mp23t4.map,mp33u4.map,mp34u4.map,mp17t6.map,mp20t6.map,mp25t6.map,mp26s6.map,mp30s6.map,mp22s8.map,mp27t8.map,mp32s8.map,mp06mw.map,mp08mw.map,mp14mw.map,mp29mw.map, 26 | mp05mw.map,mp13mw.map,mp15mw.map,mp16mw.map,mp23mw.map,mp17mw.map,mp25mw.map,mp30mw.map,mp22mw.map,mp27mw.map,mp32mw.map,mp09du.map,mp01du.map,mp05du.map,mp13du.map,mp15du.map,mp18du.map, 27 | mp24du.map,mp17du.map,mp25du.map,mp27du.map,mp32du.map,c1m1a.map,c1m1b.map,c1m1c.map,c1m2a.map,c1m2b.map,c1m2c.map,c1m3a.map,c1m3b.map,c1m3c.map,c1m4a.map,c1m4b.map,c1m4c.map,c1m5a.map, 28 | c1m5b.map,c1m5c.map,c2m1a.map,c2m1b.map,c2m1c.map,c2m2a.map,c2m2b.map,c2m2c.map,c2m3a.map,c2m3b.map,c2m3c.map,c2m4a.map,c2m4b.map,c2m4c.map,c2m5a.map,c2m5b.map,c2m5c.map,c3m1a.map,c3m1b.map, 29 | c3m1c.map,c3m2a.map,c3m2b.map,c3m2c.map,c3m3a.map,c3m3b.map,c3m3c.map,c3m4a.map,c3m4b.map,c3m4c.map,c3m5a.map,c3m5b.map,c3m5c.map,c4m1a.map,c4m1b.map,c4m1c.map,c4m2a.map,c4m2b.map,c4m2c.map, 30 | c4m3a.map,c4m3b.map,c4m3c.map,c4m4a.map,c4m4b.map,c4m4c.map,c4m5a.map,c4m5b.map,c4m5c.map,c5m1a.map,c5m1b.map,c5m1c.map,c5m2a.map,c5m2b.map,c5m2c.map,c5m3a.map,c5m3b.map,c5m3c.map,c5m4a.map, 31 | c5m4b.map,c5m4c.map,c5m5a.map,c5m5b.map,c5m5c.map,tn01t2.map,tn01mw.map,tn04t2.map,tn04mw.map,tn02s4.map,tn02mw.map,amazon01.map,eb1.map,eb2.map,eb3.map,eb4.map,eb5.map,invasion.map,arena.map, 32 | barrel.map,bayopigs.map,bermuda.map,break.map,carville.map,deadman.map,death.map,disaster.map,dustbowl.map,goldst.map,grinder.map,hailmary.map,hills.map,kaliforn.map,killer.map,lostlake.map, 33 | newhghts.map,oceansid.map,pacific.map,potomac.map,powdrkeg.map,rockets.map,roulette.map,round.map,seaofiso.map,shrapnel.map,tanyas.map,tower.map,tsunami.map,valley.map,xmas.map,yuriplot.map, 34 | cavernsofsiberia.map,countryswingfixed.map,4_country_swing_le_v2.map,dorado_descent_yr_port.mpr,dryheat.map,dunepatrolremake.map,heckbvb.map,heckcorners.map,heckgolden.mpr,heckcorners_b.map, 35 | heckcorners_b_golden.map,hecklvl.map,heckrvr.map,hecktvt.map,isleland.map,jungleofvietnam.map,2_malibu_cliffs_le.map,mojosprt.map,4_montana_dmz_le.map,6_near_ore_far.map,8_near_ore_far.map, 36 | offensedefense.map,ore2_startfixed.map,rekoool_fast_6players.mpr,rekoool_fast_8players.mpr,riverram.map,tourofegypt.map,unrepent.map,sinkswim_yr_port.map 37 | */ 38 | const mapName = "mp19t4.map"; 39 | // Bot names must be unique in online mode 40 | const timestamp = String(Date.now()).substr(-6); 41 | const botName1 = `Joe${timestamp}`; 42 | const botName2 = `Bob${timestamp}`; 43 | const botName3 = `Mike${timestamp}`; 44 | const botName4 = `Charlie${timestamp}`; 45 | const botName5 = `Phil${timestamp}`; 46 | const botName6 = `Sam${timestamp}`; 47 | const botName7 = `Ben${timestamp}`; 48 | const botName8 = `Jim${timestamp}`; 49 | 50 | await cdapi.init(process.env.MIX_DIR || "./"); 51 | 52 | console.log("Server URL: " + process.env.SERVER_URL!); 53 | console.log("Client URL: " + process.env.CLIENT_URL!); 54 | 55 | const baseSettings: CreateBaseOpts = { 56 | buildOffAlly: false, 57 | cratesAppear: false, 58 | credits: 10000, 59 | gameMode: cdapi.getAvailableGameModes(mapName)[0], 60 | gameSpeed: 6, 61 | mapName, 62 | mcvRepacks: true, 63 | shortGame: true, 64 | superWeapons: false, 65 | unitCount: 0, 66 | }; 67 | 68 | const onlineSettings: CreateOnlineOpts = { 69 | ...baseSettings, 70 | online: true, 71 | serverUrl: process.env.SERVER_URL!, 72 | clientUrl: process.env.CLIENT_URL!, 73 | agents: [ 74 | new SupalosaBot(process.env.ONLINE_BOT_NAME ?? botName1, Countries.USA, BotDifficulty.Hard), 75 | { name: process.env.PLAYER_NAME ?? botName2, country: Countries.FRANCE }, 76 | ] as [Bot, ...Agent[]], 77 | botPassword: process.env.ONLINE_BOT_PASSWORD ?? "default", 78 | }; 79 | 80 | const offlineSettings1v1: CreateOfflineOpts = { 81 | ...baseSettings, 82 | online: false, 83 | agents: [ 84 | new SupalosaBot(botName1, Countries.GREAT_BRITAIN, BotDifficulty.Hard, []).setDebugMode(true), 85 | new SupalosaBot(botName2, Countries.RUSSIA, BotDifficulty.Hard, []), 86 | ], 87 | }; 88 | 89 | const offlineSettings2v2: CreateOfflineOpts = { 90 | ...baseSettings, 91 | online: false, 92 | agents: [ 93 | new SupalosaBot(botName1, Countries.FRANCE, BotDifficulty.Hard, [botName2]), 94 | new SupalosaBot(botName2, Countries.RUSSIA, BotDifficulty.Hard, [botName1]).setDebugMode(true), 95 | new SupalosaBot(botName3, Countries.RUSSIA, BotDifficulty.Hard, [botName4]), 96 | new SupalosaBot(botName4, Countries.FRANCE, BotDifficulty.Hard, [botName3]), 97 | ], 98 | }; 99 | 100 | const team1 = [botName1, botName2, botName3, botName4]; 101 | const team2 = [botName5, botName6, botName7, botName8]; 102 | const offlineSettings4v4: CreateOfflineOpts = { 103 | ...baseSettings, 104 | online: false, 105 | agents: [ 106 | new SupalosaBot(botName1, Countries.FRANCE, BotDifficulty.Hard, team1), 107 | new SupalosaBot(botName2, Countries.RUSSIA, BotDifficulty.Hard, team1).setDebugMode(true), 108 | new SupalosaBot(botName3, Countries.RUSSIA, BotDifficulty.Hard, team1), 109 | new SupalosaBot(botName4, Countries.FRANCE, BotDifficulty.Hard, team1), 110 | new SupalosaBot(botName5, Countries.FRANCE, BotDifficulty.Hard, team2), 111 | new SupalosaBot(botName6, Countries.RUSSIA, BotDifficulty.Hard, team2), 112 | new SupalosaBot(botName7, Countries.RUSSIA, BotDifficulty.Hard, team2), 113 | new SupalosaBot(botName8, Countries.FRANCE, BotDifficulty.Hard, team2), 114 | ], 115 | }; 116 | 117 | const game = await cdapi.createGame(process.env.ONLINE_MATCH ? onlineSettings : offlineSettings1v1); 118 | 119 | console.profile(`cpuprofile-${timestamp}`); 120 | 121 | while (!game.isFinished()) { 122 | if (!!MAX_GAME_LENGTH_SECONDS && game.getCurrentTick() / 15 > MAX_GAME_LENGTH_SECONDS) { 123 | console.log(`Game forced to end due to timeout`); 124 | break; 125 | } 126 | await game.update(); 127 | } 128 | 129 | game.saveReplay(); 130 | game.dispose(); 131 | console.profileEnd(); 132 | } 133 | 134 | main().catch((e) => { 135 | console.error(e); 136 | process.exit(1); 137 | }); 138 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Visit https://aka.ms/tsconfig.json to read more about this file */ 4 | 5 | /* Basic Options */ 6 | // "incremental": true, /* Enable incremental compilation */ 7 | "target": "es2020" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', 'ES2021', or 'ESNEXT'. */, 8 | "module": "es2020" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */, 9 | // "lib": [], /* Specify library files to be included in the compilation. */ 10 | // "allowJs": true, /* Allow javascript files to be compiled. */ 11 | // "checkJs": true, /* Report errors in .js files. */ 12 | // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', 'react', 'react-jsx' or 'react-jsxdev'. */ 13 | // "declaration": true, /* Generates corresponding '.d.ts' file. */ 14 | // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ 15 | "sourceMap": true, /* Generates corresponding '.map' file. */ 16 | // "outFile": "./", /* Concatenate and emit output to single file. */ 17 | "outDir": "./dist" /* Redirect output structure to the directory. */, 18 | // "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 19 | // "composite": true, /* Enable project compilation */ 20 | // "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */ 21 | // "removeComments": true, /* Do not emit comments to output. */ 22 | // "noEmit": true, /* Do not emit outputs. */ 23 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */ 24 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 25 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 26 | 27 | /* Strict Type-Checking Options */ 28 | "strict": true /* Enable all strict type-checking options. */, 29 | // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ 30 | // "strictNullChecks": true, /* Enable strict null checks. */ 31 | // "strictFunctionTypes": true, /* Enable strict checking of function types. */ 32 | // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ 33 | // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ 34 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ 35 | "alwaysStrict": true /* Parse in strict mode and emit "use strict" for each source file. */, 36 | 37 | /* Additional Checks */ 38 | // "noUnusedLocals": true, /* Report errors on unused locals. */ 39 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 40 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 41 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 42 | // "noUncheckedIndexedAccess": true, /* Include 'undefined' in index signature results */ 43 | // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an 'override' modifier. */ 44 | // "noPropertyAccessFromIndexSignature": true, /* Require undeclared properties from index signatures to use element accesses. */ 45 | 46 | /* Module Resolution Options */ 47 | "moduleResolution": "node" /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */, 48 | //"baseUrl": "./" /* Base directory to resolve non-absolute module names. */, 49 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 50 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 51 | // "typeRoots": [], /* List of folders to include type definitions from. */ 52 | // "types": [], /* Type declaration files to be included in compilation. */ 53 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 54 | "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */, 55 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 56 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 57 | 58 | /* Source Map Options */ 59 | // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 60 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 61 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 62 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 63 | 64 | /* Experimental Options */ 65 | // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ 66 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ 67 | 68 | /* Advanced Options */ 69 | "skipLibCheck": true /* Skip type checking of declaration files. */, 70 | "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */ 71 | }, 72 | "include": ["src/**/*"] 73 | } 74 | --------------------------------------------------------------------------------