├── .prettierrc ├── src ├── bot │ ├── logic │ │ ├── composition │ │ │ ├── common.ts │ │ │ ├── sovietCompositions.ts │ │ │ └── alliedCompositions.ts │ │ ├── mission │ │ │ ├── missions │ │ │ │ ├── squads │ │ │ │ │ ├── squad.ts │ │ │ │ │ ├── common.ts │ │ │ │ │ └── combatSquad.ts │ │ │ │ ├── retreatMission.ts │ │ │ │ ├── expansionMission.ts │ │ │ │ ├── engineerMission.ts │ │ │ │ ├── defenceMission.ts │ │ │ │ ├── scoutingMission.ts │ │ │ │ └── attackMission.ts │ │ │ ├── missionFactories.ts │ │ │ ├── actionBatcher.ts │ │ │ ├── mission.ts │ │ │ └── missionController.ts │ │ ├── threat │ │ │ ├── threat.ts │ │ │ └── threatCalculator.ts │ │ ├── building │ │ │ ├── powerPlant.ts │ │ │ ├── common.ts │ │ │ ├── harvester.ts │ │ │ ├── artilleryUnit.ts │ │ │ ├── basicAirUnit.ts │ │ │ ├── basicGroundUnit.ts │ │ │ ├── basicBuilding.ts │ │ │ ├── antiGroundStaticDefence.ts │ │ │ ├── resourceCollectionBuilding.ts │ │ │ ├── antiAirStaticDefence.ts │ │ │ ├── buildingRules.ts │ │ │ └── queueController.ts │ │ ├── map │ │ │ ├── map.ts │ │ │ └── sector.ts │ │ ├── common │ │ │ ├── utils.ts │ │ │ └── scout.ts │ │ └── awareness.ts │ └── bot.ts └── exampleBot.ts ├── .gitignore ├── .npmignore ├── .env.template ├── package.json ├── TODO.md ├── README-CN.md ├── README.md └── tsconfig.json /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 4, 3 | "useTabs": false, 4 | "printWidth": 120 5 | } 6 | -------------------------------------------------------------------------------- /src/bot/logic/composition/common.ts: -------------------------------------------------------------------------------- 1 | export type UnitComposition = { 2 | [unitType: string]: number; 3 | }; 4 | -------------------------------------------------------------------------------- /.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 -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.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" -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@supalosa/chronodivide-bot", 3 | "version": "0.5.4", 4 | "description": "Example bot for Chrono Divide", 5 | "repository": "https://github.com/Supalosa/supalosa-chronodivide-bot", 6 | "main": "dist/exampleBot.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/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/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/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/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/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/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/mission/missionFactories.ts: -------------------------------------------------------------------------------- 1 | import { GameApi, PlayerData } 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 { AttackMissionFactory } 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 playerData 20 | * @param matchAwareness 21 | * @param missionController 22 | */ 23 | maybeCreateMissions( 24 | gameApi: GameApi, 25 | playerData: PlayerData, 26 | matchAwareness: MatchAwareness, 27 | missionController: MissionController, 28 | logger: DebugLogger, 29 | ): void; 30 | 31 | /** 32 | * Called when any mission fails - can be used to trigger another mission in response. 33 | */ 34 | onMissionFailed( 35 | gameApi: GameApi, 36 | playerData: PlayerData, 37 | matchAwareness: MatchAwareness, 38 | failedMission: Mission, 39 | failureReason: any, 40 | missionController: MissionController, 41 | logger: DebugLogger, 42 | ): void; 43 | } 44 | 45 | export const createMissionFactories = () => [ 46 | new ExpansionMissionFactory(), 47 | new ScoutingMissionFactory(), 48 | new AttackMissionFactory(), 49 | new DefenceMissionFactory(), 50 | new EngineerMissionFactory(), 51 | ]; 52 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /src/bot/logic/mission/missions/retreatMission.ts: -------------------------------------------------------------------------------- 1 | import { DebugLogger } from "../../common/utils.js"; 2 | import { ActionsApi, GameApi, OrderType, PlayerData, 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/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/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/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/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 | -------------------------------------------------------------------------------- /README-CN.md: -------------------------------------------------------------------------------- 1 | # Supalosa关于网页红警AI的实现 2 | 3 | [English Version Doc](README.md) 4 | 5 | [Chrono Divide](https://chronodivide.com/) 是一个在浏览器中重新构建的红色警戒2游戏。它目前已经具备完整的功能,并允许与其他玩家进行在线对战。 6 | 7 | 它还提供了[一个构建机器人的 API](https://discord.com/channels/771701199812558848/842700851520339988),但正如你所见,正式的AI并未在游戏中放开。 8 | 9 | 这个仓库是一个这样的机器人实现。 10 | 11 | ## 开发状态 12 | 13 | 目前随着越来越多的人加入到合作开发中,网页红警正式放开AI的脚步越来越近了。但距离完善的AI还有很大差距,希望大家都能参与本仓库的贡献! 14 | 15 | ## 未来计划 16 | 17 | 我目前正在同时进行三个任务: 18 | 19 | - 任务系统 - 不仅遵循实际的建造顺序,还可以管理攻击、骚扰/攻击敌人、侦查、扩展到其他基地等等。 20 | - 队伍系统 - 能够独立控制多个单位集合(即队伍),例如由骚扰任务指导的骚扰队伍。 21 | - 地图控制系统 - 能够分析地图状态,并决定是否争夺控制权。目前,我们已经将地图划分为具有单独威胁计算的方块区域,但对该信息并没有做太多处理。 22 | 23 | 这些概念中的很多已经被集成到我的《星际争霸2》机器人 [Supabot](https://github.com/Supalosa/supabot) 中,也许我完成那个项目后会回到这里。 24 | 25 | ## 安装说明 26 | 27 | 请使用Node.js 14版本,推荐使用nvm管理Node版本,方便切换。 28 | 29 | 免费GPT资源可以访问 https://igpt.wang 这有助于帮助你解决开发调试中的问题~ 30 | 31 | 建议使用官方原版红色警戒2安装。如果你更改了游戏ini,那么可能无法运行,请知悉! 32 | 33 | ```sh 34 | npm install 35 | npm run build 36 | npx cross-env MIX_DIR="C:\指向你安装的红色警戒2目录" npm start 37 | ``` 38 | 39 | 这将创建一个回放(`.rpl`)文件,可以[导入到实际游戏中](https://game.chronodivide.com/)。 40 | 41 | ## 与机器人对战 42 | 43 | 如果你真正有兴趣与机器人对战(无论是这个机器人还是你自己的机器人),请联系 Chrono Divide 的开发者以获取详细信息。 44 | 45 | ## 调试 46 | 47 | ```sh 48 | npx cross-env MIX_DIR="C:\指向你安装的红色警戒2目录" npm --node-options="${NODE_OPTIONS} --inspect" start 49 | ``` 50 | 51 | ## 发布 52 | 53 | 将 npmjs token 放在 ~/.npmrc 或适当的位置。 54 | 55 | ```bash 56 | npm publish 57 | ``` 58 | 59 | # 忽略以下内容 60 | 61 | ```bash 62 | # 开发电脑 63 | export GAMEPATH="G:\Origin\Ra2_YurisRevenge\Command and Conquer Red Alert II" 64 | # 开发笔记本电脑 65 | export GAMEPATH="D:\EA Games\Command and Conquer Red Alert II" 66 | 67 | --- 68 | 69 | # 不带任何调试运行 70 | npm run build && npx cross-env MIX_DIR="${GAMEPATH}" npm start 71 | 72 | # 带有附加调试器运行 73 | npm run build && npx cross-env MIX_DIR="${GAMEPATH}" npm --node-options="${NODE_OPTIONS} --inspect" start 74 | 75 | # 带有附加调试器和详细的 API 日志记录运行 76 | npm run build && DEBUG_LOGGING="action" npx cross-env MIX_DIR="${GAMEPATH}" npm --node-options="${NODE_OPTIONS} --inspect" start 77 | 78 | # DEBUG_LOGGING 也可以缩小范围,例如 "action" 或 "move" 79 | ``` 80 | 81 | 如果你想在观看回放时渲染游戏内的调试文本,请在开发控制台中输入以下内容: 82 | 这将在第二个位置上调试机器人。(你无法调试第一个机器人,因为将 `debug_bot` 设置为 `0` 将禁用调试功能)。 83 | 84 | ```js 85 | r.debug_bot = 1; 86 | r.debug_text = true; 87 | ``` 88 | 89 | --- 90 | 91 | 天梯地图可以参考:https://github.com/chronodivide/pvpgn-server/blob/26bbbe39613751cff696a73f087ce5b4cd938fc8/conf/bnmaps.conf.in#L321-L328 92 | 93 | CDR2 1v1 2_malibu_cliffs_le.map 94 | CDR2 1v1 4_country_swing_le_v2.map 95 | CDR2 1v1 mp01t4.map 96 | CDR2 1v1 tn04t2.map 97 | CDR2 1v1 mp10s4.map 98 | CDR2 1v1 heckcorners.map 99 | CDR2 1v1 4_montana_dmz_le.map 100 | CDR2 1v1 barrel.map 101 | 102 | --- 103 | 104 | 与机器人对战 105 | 106 | ```bash 107 | export SERVER_URL="wss://" 108 | export CLIENT_URL="https://game.chronodivide.com/" 109 | ``` -------------------------------------------------------------------------------- /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 { 10 | if (attacker.name === "E1") { 11 | const isDeployed = attacker.stance === StanceType.Deployed; 12 | if (isDeployed) { 13 | return BatchableAction.noTarget(attacker.id, OrderType.DeploySelected, NONCE_GI_UNDEPLOY); 14 | } 15 | } 16 | 17 | return BatchableAction.toPoint(attacker.id, OrderType.AttackMove, attackPoint); 18 | } 19 | 20 | export function manageAttackMicro(attacker: UnitData, target: UnitData): BatchableAction { 21 | const distance = getDistanceBetweenUnits(attacker, target); 22 | if (attacker.name === "E1") { 23 | // Para (deployed weapon) range is 5. 24 | const deployedWeaponRange = attacker.secondaryWeapon?.maxRange || 5; 25 | const isDeployed = attacker.stance === StanceType.Deployed; 26 | if (!isDeployed && (distance <= deployedWeaponRange || attacker.attackState === AttackState.JustFired)) { 27 | return BatchableAction.noTarget(attacker.id, OrderType.DeploySelected, NONCE_GI_DEPLOY); 28 | } else if (isDeployed && distance > deployedWeaponRange) { 29 | return BatchableAction.noTarget(attacker.id, OrderType.DeploySelected, NONCE_GI_UNDEPLOY); 30 | } 31 | } 32 | let targetData = target; 33 | let orderType: OrderType = OrderType.Attack; 34 | const primaryWeaponRange = attacker.primaryWeapon?.maxRange || 5; 35 | if (targetData?.type == ObjectType.Building && distance < primaryWeaponRange * 0.8) { 36 | orderType = OrderType.Attack; 37 | } else if (targetData?.rules.canDisguise) { 38 | // Special case for mirage tank/spy as otherwise they just sit next to it. 39 | orderType = OrderType.Attack; 40 | } 41 | return BatchableAction.toTargetId(attacker.id, orderType, target.id); 42 | } 43 | 44 | /** 45 | * 46 | * @param attacker 47 | * @param target 48 | * @returns A number describing the weight of the given target for the attacker, or null if it should not attack it. 49 | */ 50 | export function getAttackWeight(attacker: UnitData, target: UnitData): number | null { 51 | const { rx: x, ry: y } = attacker.tile; 52 | const { rx: hX, ry: hY } = target.tile; 53 | 54 | if (!attacker.primaryWeapon?.projectileRules.isAntiAir && target.zone === ZoneType.Air) { 55 | return null; 56 | } 57 | 58 | if (!attacker.primaryWeapon?.projectileRules.isAntiGround && target.zone === ZoneType.Ground) { 59 | return null; 60 | } 61 | 62 | return 1000000 - getDistanceBetweenPoints(new Vector2(x, y), new Vector2(hX, hY)); 63 | } 64 | -------------------------------------------------------------------------------- /src/bot/logic/mission/missions/expansionMission.ts: -------------------------------------------------------------------------------- 1 | import { ActionsApi, GameApi, OrderType, PlayerData } 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 | playerData: PlayerData, 83 | matchAwareness: MatchAwareness, 84 | missionController: MissionController, 85 | logger: DebugLogger, 86 | ): void { 87 | // At this point, only expand if we have a loose MCV. 88 | const mcvs = gameApi.getVisibleUnits(playerData.name, "self", (r) => 89 | gameApi.getGeneralRules().baseUnit.includes(r.name), 90 | ); 91 | mcvs.forEach((mcv) => { 92 | missionController.addMission(new ExpansionMission("expand-with-" + mcv, 100, mcv, logger)); 93 | }); 94 | } 95 | 96 | onMissionFailed( 97 | gameApi: GameApi, 98 | playerData: PlayerData, 99 | matchAwareness: MatchAwareness, 100 | failedMission: Mission, 101 | failureReason: undefined, 102 | missionController: MissionController, 103 | ): void {} 104 | } 105 | -------------------------------------------------------------------------------- /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 array[minIdx]; 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 array[maxIdx]; 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 | -------------------------------------------------------------------------------- /src/bot/logic/mission/missions/engineerMission.ts: -------------------------------------------------------------------------------- 1 | import { ActionsApi, GameApi, OrderType, PlayerData } 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 | 76 | export class EngineerMissionFactory implements MissionFactory { 77 | private lastCheckAt = 0; 78 | 79 | getName(): string { 80 | return "EngineerMissionFactory"; 81 | } 82 | 83 | maybeCreateMissions( 84 | gameApi: GameApi, 85 | playerData: PlayerData, 86 | matchAwareness: MatchAwareness, 87 | missionController: MissionController, 88 | logger: DebugLogger, 89 | ): void { 90 | if (!(gameApi.getCurrentTick() > this.lastCheckAt + TECH_CHECK_INTERVAL_TICKS)) { 91 | return; 92 | } 93 | this.lastCheckAt = gameApi.getCurrentTick(); 94 | const eligibleTechBuildings = gameApi.getVisibleUnits( 95 | playerData.name, 96 | "hostile", 97 | (r) => r.capturable && r.produceCashAmount > 0, 98 | ); 99 | 100 | eligibleTechBuildings.forEach((techBuildingId) => { 101 | missionController.addMission(new EngineerMission("capture-" + techBuildingId, 100, techBuildingId, logger)); 102 | }); 103 | } 104 | 105 | onMissionFailed( 106 | gameApi: GameApi, 107 | playerData: PlayerData, 108 | matchAwareness: MatchAwareness, 109 | failedMission: Mission, 110 | failureReason: undefined, 111 | missionController: MissionController, 112 | ): void {} 113 | } 114 | -------------------------------------------------------------------------------- /src/bot/logic/threat/threatCalculator.ts: -------------------------------------------------------------------------------- 1 | import { GameApi, GameMath, MovementZone, ObjectType, PlayerData, UnitData } from "@chronodivide/game-api"; 2 | import { GlobalThreat } from "./threat.js"; 3 | 4 | export function calculateGlobalThreat(game: GameApi, playerData: PlayerData, visibleAreaPercent: number): GlobalThreat { 5 | let groundUnits = game.getVisibleUnits( 6 | playerData.name, 7 | "enemy", 8 | (r) => r.type == ObjectType.Vehicle || r.type == ObjectType.Infantry, 9 | ); 10 | let airUnits = game.getVisibleUnits(playerData.name, "enemy", (r) => r.movementZone == MovementZone.Fly); 11 | let groundDefence = game 12 | .getVisibleUnits(playerData.name, "enemy", (r) => r.type == ObjectType.Building) 13 | .filter((unitId) => isAntiGround(game, unitId)); 14 | let antiAirPower = game 15 | .getVisibleUnits(playerData.name, "enemy", (r) => r.type != ObjectType.Building) 16 | .filter((unitId) => isAntiAir(game, unitId)); 17 | 18 | let ourAntiGroundUnits = game 19 | .getVisibleUnits(playerData.name, "self", (r) => r.isSelectableCombatant) 20 | .filter((unitId) => isAntiGround(game, unitId)); 21 | let ourAntiAirUnits = game 22 | .getVisibleUnits(playerData.name, "self", (r) => r.isSelectableCombatant || r.type === ObjectType.Building) 23 | .filter((unitId) => isAntiAir(game, unitId)); 24 | let ourGroundDefence = game 25 | .getVisibleUnits(playerData.name, "self", (r) => r.type === ObjectType.Building) 26 | .filter((unitId) => isAntiGround(game, unitId)); 27 | let ourAirUnits = game.getVisibleUnits( 28 | playerData.name, 29 | "self", 30 | (r) => r.movementZone == MovementZone.Fly && r.isSelectableCombatant, 31 | ); 32 | 33 | let observedGroundThreat = calculateFirepowerForUnits(game, groundUnits); 34 | let observedAirThreat = calculateFirepowerForUnits(game, airUnits); 35 | let observedAntiAirThreat = calculateFirepowerForUnits(game, antiAirPower); 36 | let observedGroundDefence = calculateFirepowerForUnits(game, groundDefence); 37 | 38 | let ourAntiGroundPower = calculateFirepowerForUnits(game, ourAntiGroundUnits); 39 | let ourAntiAirPower = calculateFirepowerForUnits(game, ourAntiAirUnits); 40 | let ourAirPower = calculateFirepowerForUnits(game, ourAirUnits); 41 | let ourGroundDefencePower = calculateFirepowerForUnits(game, ourGroundDefence); 42 | 43 | return new GlobalThreat( 44 | visibleAreaPercent, 45 | observedGroundThreat, 46 | observedAirThreat, 47 | observedAntiAirThreat, 48 | observedGroundDefence, 49 | ourGroundDefencePower, 50 | ourAntiGroundPower, 51 | ourAntiAirPower, 52 | ourAirPower, 53 | ); 54 | } 55 | 56 | function isAntiGround(gameApi: GameApi, unitId: number): boolean { 57 | let unit = gameApi.getUnitData(unitId); 58 | if (unit && unit.primaryWeapon) { 59 | return unit.primaryWeapon.projectileRules.isAntiGround; 60 | } 61 | return false; 62 | } 63 | 64 | function isAntiAir(gameApi: GameApi, unitId: number): boolean { 65 | let unit = gameApi.getUnitData(unitId); 66 | if (unit && unit.primaryWeapon) { 67 | return unit.primaryWeapon.projectileRules.isAntiAir; 68 | } 69 | return false; 70 | } 71 | 72 | function calculateFirepowerForUnit(unitData: UnitData): number { 73 | let threat = 0; 74 | let hpRatio = unitData.hitPoints / Math.max(1, unitData.maxHitPoints); 75 | if (unitData.primaryWeapon) { 76 | threat += 77 | (hpRatio * 78 | ((unitData.primaryWeapon.rules.damage + 1) * GameMath.sqrt(unitData.primaryWeapon.rules.range + 1))) / 79 | Math.max(unitData.primaryWeapon.rules.rof, 1); 80 | } 81 | if (unitData.secondaryWeapon) { 82 | threat += 83 | (hpRatio * 84 | ((unitData.secondaryWeapon.rules.damage + 1) * 85 | GameMath.sqrt(unitData.secondaryWeapon.rules.range + 1))) / 86 | Math.max(unitData.secondaryWeapon.rules.rof, 1); 87 | } 88 | return Math.min(800, threat); 89 | } 90 | 91 | function calculateFirepowerForUnits(game: GameApi, unitIds: number[]) { 92 | let threat = 0; 93 | unitIds.forEach((unitId) => { 94 | let unitData = game.getUnitData(unitId); 95 | if (unitData) { 96 | threat += calculateFirepowerForUnit(unitData); 97 | } 98 | }); 99 | return threat; 100 | } 101 | -------------------------------------------------------------------------------- /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: CN Documentation 104 | -------------------------------------------------------------------------------- /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/missions/defenceMission.ts: -------------------------------------------------------------------------------- 1 | import { ActionsApi, GameApi, PlayerData, 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 | playerData: PlayerData, 108 | matchAwareness: MatchAwareness, 109 | missionController: MissionController, 110 | logger: DebugLogger, 111 | ): void { 112 | if (gameApi.getCurrentTick() < this.lastDefenceCheckAt + DEFENCE_CHECK_TICKS) { 113 | return; 114 | } 115 | this.lastDefenceCheckAt = gameApi.getCurrentTick(); 116 | 117 | const defendableRadius = 118 | DEFENCE_STARTING_RADIUS + DEFENCE_RADIUS_INCREASE_PER_GAME_TICK * gameApi.getCurrentTick(); 119 | const enemiesNearSpawn = matchAwareness 120 | .getHostilesNearPoint2d(playerData.startLocation, defendableRadius) 121 | .map((unit) => gameApi.getUnitData(unit.unitId)) 122 | .filter((unit) => !isOwnedByNeutral(unit)) as UnitData[]; 123 | 124 | if (enemiesNearSpawn.length > 0) { 125 | logger( 126 | `Starting defence mission, ${ 127 | enemiesNearSpawn.length 128 | } found in radius ${defendableRadius} (tick ${gameApi.getCurrentTick()})`, 129 | ); 130 | missionController.addMission( 131 | new DefenceMission( 132 | "globalDefence", 133 | 10, 134 | matchAwareness.getMainRallyPoint(), 135 | playerData.startLocation, 136 | defendableRadius * 1.2, 137 | logger, 138 | ), 139 | ); 140 | } 141 | } 142 | 143 | onMissionFailed( 144 | gameApi: GameApi, 145 | playerData: PlayerData, 146 | matchAwareness: MatchAwareness, 147 | failedMission: Mission, 148 | failureReason: undefined, 149 | missionController: MissionController, 150 | ): void {} 151 | } 152 | -------------------------------------------------------------------------------- /src/exampleBot.ts: -------------------------------------------------------------------------------- 1 | import "dotenv/config"; 2 | import { Agent, Bot, CreateBaseOpts, CreateOfflineOpts, CreateOnlineOpts, cdapi } from "@chronodivide/game-api"; 3 | import { SupalosaBot } from "./bot/bot.js"; 4 | import { Countries } from "./bot/logic/common/utils.js"; 5 | 6 | // The game will automatically end after this time. This is to handle stalemates. 7 | const MAX_GAME_LENGTH_SECONDS: number | null = 7200; // 7200 = two hours 8 | 9 | async function main() { 10 | /* 11 | Ladder maps: 12 | CDR2 1v1 2_malibu_cliffs_le.map 13 | CDR2 1v1 4_country_swing_le_v2.map 14 | CDR2 1v1 mp01t4.map, large map, oil derricks 15 | CDR2 1v1 tn04t2.map, small map 16 | CDR2 1v1 mp10s4.map <- depth charge, naval map (not supported). Cramped in position 1. 17 | CDR2 1v1 heckcorners.map 18 | CDR2 1v1 4_montana_dmz_le.map 19 | CDR2 1v1 barrel.map 20 | 21 | Other maps: 22 | mp03t4 large map, no oil derricks 23 | 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, 24 | 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, 25 | 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, 26 | 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, 27 | 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, 28 | 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, 29 | 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, 30 | 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, 31 | 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, 32 | 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, 33 | 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, 34 | 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, 35 | offensedefense.map,ore2_startfixed.map,rekoool_fast_6players.mpr,rekoool_fast_8players.mpr,riverram.map,tourofegypt.map,unrepent.map,sinkswim_yr_port.map 36 | */ 37 | const mapName = "heckcorners_b.map"; 38 | // Bot names must be unique in online mode 39 | const timestamp = String(Date.now()).substr(-6); 40 | const firstBotName = `Joe${timestamp}`; 41 | const secondBotName = `Bob${timestamp}`; 42 | const thirdBotName = `Mike${timestamp}`; 43 | const fourthBotName = `Charlie${timestamp}`; 44 | 45 | await cdapi.init(process.env.MIX_DIR || "./"); 46 | 47 | console.log("Server URL: " + process.env.SERVER_URL!); 48 | console.log("Client URL: " + process.env.CLIENT_URL!); 49 | 50 | const baseSettings: CreateBaseOpts = { 51 | buildOffAlly: false, 52 | cratesAppear: false, 53 | credits: 10000, 54 | gameMode: cdapi.getAvailableGameModes(mapName)[0], 55 | gameSpeed: 6, 56 | mapName, 57 | mcvRepacks: true, 58 | shortGame: true, 59 | superWeapons: false, 60 | unitCount: 0, 61 | }; 62 | 63 | const onlineSettings: CreateOnlineOpts = { 64 | ...baseSettings, 65 | online: true, 66 | serverUrl: process.env.SERVER_URL!, 67 | clientUrl: process.env.CLIENT_URL!, 68 | agents: [ 69 | new SupalosaBot(process.env.ONLINE_BOT_NAME ?? firstBotName, Countries.USA), 70 | { name: process.env.PLAYER_NAME ?? secondBotName, country: Countries.FRANCE }, 71 | ] as [Bot, ...Agent[]], 72 | botPassword: process.env.ONLINE_BOT_PASSWORD ?? "default", 73 | }; 74 | 75 | const offlineSettings1v1: CreateOfflineOpts = { 76 | ...baseSettings, 77 | online: false, 78 | agents: [ 79 | new SupalosaBot(firstBotName, Countries.FRANCE, [], false), 80 | new SupalosaBot(secondBotName, Countries.RUSSIA, [], true).setDebugMode(true), 81 | ], 82 | }; 83 | 84 | const offlineSettings2v2: CreateOfflineOpts = { 85 | ...baseSettings, 86 | online: false, 87 | agents: [ 88 | new SupalosaBot(firstBotName, Countries.FRANCE, [firstBotName], false), 89 | new SupalosaBot(secondBotName, Countries.RUSSIA, [firstBotName], true).setDebugMode(true), 90 | new SupalosaBot(thirdBotName, Countries.RUSSIA, [fourthBotName], false), 91 | new SupalosaBot(fourthBotName, Countries.FRANCE, [thirdBotName], false), 92 | ], 93 | }; 94 | 95 | const game = await cdapi.createGame(process.env.ONLINE_MATCH ? onlineSettings : offlineSettings1v1); 96 | while (!game.isFinished()) { 97 | if (!!MAX_GAME_LENGTH_SECONDS && game.getCurrentTick() / 15 > MAX_GAME_LENGTH_SECONDS) { 98 | console.log(`Game forced to end due to timeout`); 99 | break; 100 | } 101 | await game.update(); 102 | } 103 | 104 | game.saveReplay(); 105 | game.dispose(); 106 | } 107 | 108 | main().catch((e) => { 109 | console.error(e); 110 | process.exit(1); 111 | }); 112 | -------------------------------------------------------------------------------- /src/bot/bot.ts: -------------------------------------------------------------------------------- 1 | import { ApiEventType, Bot, GameApi, ApiEvent, ObjectType, FactoryType, Size } from "@chronodivide/game-api"; 2 | 3 | import { determineMapBounds } from "./logic/map/map.js"; 4 | import { SectorCache } from "./logic/map/sector.js"; 5 | import { MissionController } from "./logic/mission/missionController.js"; 6 | import { QueueController } from "./logic/building/queueController.js"; 7 | import { MatchAwareness, MatchAwarenessImpl } from "./logic/awareness.js"; 8 | import { Countries, formatTimeDuration } from "./logic/common/utils.js"; 9 | 10 | const DEBUG_STATE_UPDATE_INTERVAL_SECONDS = 6; 11 | 12 | // Number of ticks per second at the base speed. 13 | const NATURAL_TICK_RATE = 15; 14 | 15 | export class SupalosaBot extends Bot { 16 | private tickRatio?: number; 17 | private knownMapBounds: Size | undefined; 18 | private missionController: MissionController; 19 | private queueController: QueueController; 20 | private tickOfLastAttackOrder: number = 0; 21 | 22 | private matchAwareness: MatchAwareness | null = null; 23 | 24 | constructor( 25 | name: string, 26 | country: Countries, 27 | private tryAllyWith: string[] = [], 28 | private enableLogging = true, 29 | ) { 30 | super(name, country); 31 | this.missionController = new MissionController((message, sayInGame) => this.logBotStatus(message, sayInGame)); 32 | this.queueController = new QueueController(); 33 | } 34 | 35 | override onGameStart(game: GameApi) { 36 | const gameRate = game.getTickRate(); 37 | const botApm = 300; 38 | const botRate = botApm / 60; 39 | this.tickRatio = Math.ceil(gameRate / botRate); 40 | 41 | this.knownMapBounds = determineMapBounds(game.mapApi); 42 | const myPlayer = game.getPlayerData(this.name); 43 | 44 | this.matchAwareness = new MatchAwarenessImpl( 45 | null, 46 | new SectorCache(game.mapApi, this.knownMapBounds), 47 | myPlayer.startLocation, 48 | (message, sayInGame) => this.logBotStatus(message, sayInGame), 49 | ); 50 | this.matchAwareness.onGameStart(game, myPlayer); 51 | 52 | this.logBotStatus(`Map bounds: ${this.knownMapBounds.width}, ${this.knownMapBounds.height}`); 53 | 54 | this.tryAllyWith.forEach((playerName) => this.actionsApi.toggleAlliance(playerName, true)); 55 | } 56 | 57 | override onGameTick(game: GameApi) { 58 | if (!this.matchAwareness) { 59 | return; 60 | } 61 | 62 | const threatCache = this.matchAwareness.getThreatCache(); 63 | 64 | if ((game.getCurrentTick() / NATURAL_TICK_RATE) % DEBUG_STATE_UPDATE_INTERVAL_SECONDS === 0) { 65 | this.updateDebugState(game); 66 | } 67 | 68 | if (game.getCurrentTick() % this.tickRatio! === 0) { 69 | const myPlayer = game.getPlayerData(this.name); 70 | 71 | this.matchAwareness.onAiUpdate(game, myPlayer); 72 | 73 | // hacky resign condition 74 | const armyUnits = game.getVisibleUnits(this.name, "self", (r) => r.isSelectableCombatant); 75 | const mcvUnits = game.getVisibleUnits( 76 | this.name, 77 | "self", 78 | (r) => !!r.deploysInto && game.getGeneralRules().baseUnit.includes(r.name), 79 | ); 80 | const productionBuildings = game.getVisibleUnits( 81 | this.name, 82 | "self", 83 | (r) => r.type == ObjectType.Building && r.factory != FactoryType.None, 84 | ); 85 | if (armyUnits.length == 0 && productionBuildings.length == 0 && mcvUnits.length == 0) { 86 | this.logBotStatus(`No army or production left, quitting.`); 87 | this.actionsApi.quitGame(); 88 | } 89 | 90 | // Mission logic every 3 ticks 91 | if (this.gameApi.getCurrentTick() % 3 === 0) { 92 | this.missionController.onAiUpdate(game, this.actionsApi, myPlayer, this.matchAwareness); 93 | } 94 | 95 | const unitTypeRequests = this.missionController.getRequestedUnitTypes(); 96 | 97 | // Build logic. 98 | this.queueController.onAiUpdate( 99 | game, 100 | this.productionApi, 101 | this.actionsApi, 102 | myPlayer, 103 | threatCache, 104 | unitTypeRequests, 105 | (message) => this.logBotStatus(message), 106 | ); 107 | } 108 | } 109 | 110 | private getHumanTimestamp(game: GameApi) { 111 | return formatTimeDuration(game.getCurrentTick() / NATURAL_TICK_RATE); 112 | } 113 | 114 | private logBotStatus(message: string, sayInGame: boolean = false) { 115 | if (!this.enableLogging) { 116 | return; 117 | } 118 | this.logger.info(message); 119 | if (sayInGame) { 120 | const timestamp = this.getHumanTimestamp(this.gameApi); 121 | this.actionsApi.sayAll(`${timestamp}: ${message}`); 122 | } 123 | } 124 | 125 | private updateDebugState(game: GameApi) { 126 | if (!this.getDebugMode()) { 127 | return; 128 | } 129 | // Update the global debug text. 130 | const myPlayer = game.getPlayerData(this.name); 131 | const harvesters = game.getVisibleUnits(this.name, "self", (r) => r.harvester).length; 132 | 133 | let globalDebugText = `Cash: ${myPlayer.credits} | Harvesters: ${harvesters}\n`; 134 | globalDebugText += this.queueController.getGlobalDebugText(this.gameApi, this.productionApi); 135 | globalDebugText += this.missionController.getGlobalDebugText(this.gameApi); 136 | globalDebugText += this.matchAwareness?.getGlobalDebugText(); 137 | 138 | this.missionController.updateDebugText(this.actionsApi); 139 | 140 | // Tag enemy units with IDs 141 | game.getVisibleUnits(this.name, "enemy").forEach((unitId) => { 142 | this.actionsApi.setUnitDebugText(unitId, unitId.toString()); 143 | }); 144 | 145 | this.actionsApi.setGlobalDebugText(globalDebugText); 146 | } 147 | 148 | override onGameEvent(ev: ApiEvent) { 149 | switch (ev.type) { 150 | case ApiEventType.ObjectDestroy: { 151 | // Add to the stalemate detection. 152 | if (ev.attackerInfo?.playerName == this.name) { 153 | this.tickOfLastAttackOrder += (this.gameApi.getCurrentTick() - this.tickOfLastAttackOrder) / 2; 154 | } 155 | break; 156 | } 157 | default: 158 | break; 159 | } 160 | } 161 | } 162 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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 units = mission.getUnitsMatching(gameApi, (r) => r.rules.isSelectableCombatant); 81 | 82 | // Only use ground units for center of mass. 83 | const groundUnits = mission.getUnitsMatching( 84 | gameApi, 85 | (r) => 86 | r.rules.isSelectableCombatant && 87 | (r.rules.movementZone === MovementZone.Infantry || 88 | r.rules.movementZone === MovementZone.Normal || 89 | r.rules.movementZone === MovementZone.InfantryDestroyer), 90 | ); 91 | 92 | if (this.state === SquadState.Gathering) { 93 | const requiredGatherRadius = GameMath.sqrt(groundUnits.length) * GATHER_RATIO + MIN_GATHER_RADIUS; 94 | if ( 95 | centerOfMass && 96 | maxDistance && 97 | gameApi.mapApi.getTile(centerOfMass.x, centerOfMass.y) !== undefined && 98 | maxDistance > requiredGatherRadius 99 | ) { 100 | units.forEach((unit) => { 101 | this.submitActionIfNew(actionBatcher, manageMoveMicro(unit, centerOfMass)); 102 | }); 103 | } else { 104 | logger(`CombatSquad ${mission.getUniqueName()} switching back to attack mode (${maxDistance})`); 105 | this.state = SquadState.Attacking; 106 | } 107 | } else { 108 | const targetPoint = this.targetArea || playerData.startLocation; 109 | const requiredGatherRadius = GameMath.sqrt(groundUnits.length) * GATHER_RATIO + MAX_GATHER_RADIUS; 110 | if ( 111 | centerOfMass && 112 | maxDistance && 113 | gameApi.mapApi.getTile(centerOfMass.x, centerOfMass.y) !== undefined && 114 | maxDistance > requiredGatherRadius 115 | ) { 116 | // Switch back to gather mode 117 | logger(`CombatSquad ${mission.getUniqueName()} switching back to gather (${maxDistance})`); 118 | this.state = SquadState.Gathering; 119 | return noop(); 120 | } 121 | // The unit with the shortest range chooses the target. Otherwise, a base range of 5 is chosen. 122 | const getRangeForUnit = (unit: UnitData) => 123 | unit.primaryWeapon?.maxRange ?? unit.secondaryWeapon?.maxRange ?? 5; 124 | const attackLeader = minBy(units, getRangeForUnit); 125 | if (!attackLeader) { 126 | return noop(); 127 | } 128 | // Find units within double the range of the leader. 129 | const nearbyHostiles = matchAwareness 130 | .getHostilesNearPoint(attackLeader.tile.rx, attackLeader.tile.ry, ATTACK_SCAN_AREA) 131 | .map(({ unitId }) => gameApi.getUnitData(unitId)) 132 | .filter((unit) => !isOwnedByNeutral(unit)) as UnitData[]; 133 | 134 | for (const unit of units) { 135 | const bestUnit = maxBy(nearbyHostiles, (target) => getAttackWeight(unit, target)); 136 | if (bestUnit) { 137 | this.submitActionIfNew(actionBatcher, manageAttackMicro(unit, bestUnit)); 138 | this.debugLastTarget = `Unit ${bestUnit.id.toString()}`; 139 | } else { 140 | this.submitActionIfNew(actionBatcher, manageMoveMicro(unit, targetPoint)); 141 | this.debugLastTarget = `@${targetPoint.x},${targetPoint.y}`; 142 | } 143 | } 144 | } 145 | } 146 | return noop(); 147 | } 148 | 149 | /** 150 | * Sends an action to the acitonBatcher if and only if the action is different from the last action we submitted to it. 151 | * Prevents spamming redundant orders, which affects performance and can also ccause the unit to sit around doing nothing. 152 | */ 153 | private submitActionIfNew(actionBatcher: ActionBatcher, action: BatchableAction) { 154 | const lastAction = this.lastOrderGiven[action.unitId]; 155 | if (!lastAction || !lastAction.isSameAs(action)) { 156 | actionBatcher.push(action); 157 | this.lastOrderGiven[action.unitId] = action; 158 | } 159 | } 160 | } 161 | -------------------------------------------------------------------------------- /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/mission/missions/scoutingMission.ts: -------------------------------------------------------------------------------- 1 | import { ActionsApi, GameApi, OrderType, PlayerData, 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 | playerData: PlayerData, 151 | matchAwareness: MatchAwareness, 152 | missionController: MissionController, 153 | logger: DebugLogger, 154 | ): void { 155 | if (gameApi.getCurrentTick() < this.lastScoutAt + SCOUT_COOLDOWN_TICKS) { 156 | return; 157 | } 158 | if (!matchAwareness.getScoutingManager().hasScoutTargets()) { 159 | return; 160 | } 161 | if (!missionController.addMission(new ScoutingMission("globalScout", 10, logger))) { 162 | this.lastScoutAt = gameApi.getCurrentTick(); 163 | } 164 | } 165 | 166 | onMissionFailed( 167 | gameApi: GameApi, 168 | playerData: PlayerData, 169 | matchAwareness: MatchAwareness, 170 | failedMission: Mission, 171 | failureReason: undefined, 172 | missionController: MissionController, 173 | logger: DebugLogger, 174 | ): void { 175 | if (gameApi.getCurrentTick() < this.lastScoutAt + SCOUT_COOLDOWN_TICKS) { 176 | return; 177 | } 178 | if (!matchAwareness.getScoutingManager().hasScoutTargets()) { 179 | return; 180 | } 181 | if (failedMission instanceof AttackMission) { 182 | missionController.addMission(new ScoutingMission("globalScout", 10, logger)); 183 | this.lastScoutAt = gameApi.getCurrentTick(); 184 | } 185 | } 186 | } 187 | -------------------------------------------------------------------------------- /src/bot/logic/mission/mission.ts: -------------------------------------------------------------------------------- 1 | import { ActionsApi, GameApi, PlayerData, Tile, UnitData, Vector2 } from "@chronodivide/game-api"; 2 | import { MatchAwareness } from "../awareness.js"; 3 | import { DebugLogger } from "../common/utils.js"; 4 | import { ActionBatcher } from "./actionBatcher.js"; 5 | import { getDistanceBetweenTileAndPoint } from "../map/map.js"; 6 | 7 | const calculateCenterOfMass: (unitTiles: Tile[]) => { 8 | centerOfMass: Vector2; 9 | maxDistance: number; 10 | } | null = (unitTiles) => { 11 | if (unitTiles.length === 0) { 12 | return null; 13 | } 14 | // TODO: use median here 15 | const sums = unitTiles.reduce( 16 | ({ x, y }, tile) => { 17 | return { 18 | x: x + (tile?.rx || 0), 19 | y: y + (tile?.ry || 0), 20 | }; 21 | }, 22 | { x: 0, y: 0 }, 23 | ); 24 | const centerOfMass = new Vector2(Math.round(sums.x / unitTiles.length), Math.round(sums.y / unitTiles.length)); 25 | 26 | // max distance of units to the center of mass 27 | const distances = unitTiles.map((tile) => getDistanceBetweenTileAndPoint(tile, centerOfMass)); 28 | const maxDistance = Math.max(...distances); 29 | return { centerOfMass, maxDistance }; 30 | }; 31 | // AI starts Missions based on heuristics. 32 | export abstract class Mission { 33 | private active = true; 34 | private unitIds: number[] = []; 35 | private centerOfMass: Vector2 | null = null; 36 | private maxDistanceToCenterOfMass: number | null = null; 37 | 38 | private onFinish: (unitIds: number[], reason: FailureReasons) => void = () => {}; 39 | 40 | constructor( 41 | private uniqueName: string, 42 | protected logger: DebugLogger, 43 | ) {} 44 | 45 | // TODO call this 46 | protected updateCenterOfMass(gameApi: GameApi) { 47 | const movableUnitTiles = this.unitIds 48 | .map((unitId) => gameApi.getUnitData(unitId)) 49 | .filter((unit) => unit?.canMove) 50 | .map((unit) => unit?.tile) 51 | .filter((tile) => !!tile) as Tile[]; 52 | const tileMetrics = calculateCenterOfMass(movableUnitTiles); 53 | if (tileMetrics) { 54 | this.centerOfMass = tileMetrics.centerOfMass; 55 | this.maxDistanceToCenterOfMass = tileMetrics.maxDistance; 56 | } else { 57 | this.centerOfMass = null; 58 | this.maxDistanceToCenterOfMass = null; 59 | } 60 | } 61 | 62 | public onAiUpdate( 63 | gameApi: GameApi, 64 | actionsApi: ActionsApi, 65 | playerData: PlayerData, 66 | matchAwareness: MatchAwareness, 67 | actionBatcher: ActionBatcher, 68 | ): MissionAction { 69 | this.updateCenterOfMass(gameApi); 70 | return this._onAiUpdate(gameApi, actionsApi, playerData, matchAwareness, actionBatcher); 71 | } 72 | 73 | // TODO: fix this weird indirection 74 | abstract _onAiUpdate( 75 | gameApi: GameApi, 76 | actionsApi: ActionsApi, 77 | playerData: PlayerData, 78 | matchAwareness: MatchAwareness, 79 | actionBatcher: ActionBatcher, 80 | ): MissionAction; 81 | 82 | isActive(): boolean { 83 | return this.active; 84 | } 85 | 86 | public getUnitIds(): number[] { 87 | return this.unitIds; 88 | } 89 | 90 | public removeUnit(unitIdToRemove: number): void { 91 | this.unitIds = this.unitIds.filter((unitId) => unitId != unitIdToRemove); 92 | } 93 | 94 | public addUnit(unitIdToAdd: number): void { 95 | this.unitIds.push(unitIdToAdd); 96 | } 97 | 98 | public getUnits(gameApi: GameApi): UnitData[] { 99 | return this.unitIds 100 | .map((unitId) => gameApi.getUnitData(unitId)) 101 | .filter((unit) => unit != null) 102 | .map((unit) => unit!); 103 | } 104 | 105 | public getUnitsOfTypes(gameApi: GameApi, ...names: string[]): UnitData[] { 106 | return this.unitIds 107 | .map((unitId) => gameApi.getUnitData(unitId)) 108 | .filter((unit) => !!unit && names.includes(unit.name)) 109 | .map((unit) => unit!); 110 | } 111 | 112 | public getUnitsMatching(gameApi: GameApi, filter: (r: UnitData) => boolean): UnitData[] { 113 | return this.unitIds 114 | .map((unitId) => gameApi.getUnitData(unitId)) 115 | .filter((unit) => !!unit && filter(unit)) 116 | .map((unit) => unit!); 117 | } 118 | 119 | public getCenterOfMass() { 120 | return this.centerOfMass; 121 | } 122 | 123 | public getMaxDistanceToCenterOfMass() { 124 | return this.maxDistanceToCenterOfMass; 125 | } 126 | 127 | getUniqueName(): string { 128 | return this.uniqueName; 129 | } 130 | 131 | // Don't call this from the mission itself 132 | endMission(reason: FailureReasons): void { 133 | this.onFinish(this.unitIds, reason); 134 | this.active = false; 135 | } 136 | 137 | /** 138 | * Declare a callback that is executed when the mission is disbanded for whatever reason. 139 | */ 140 | then(onFinish: (unitIds: number[], reason: FailureReasons) => void): Mission { 141 | this.onFinish = onFinish; 142 | return this; 143 | } 144 | 145 | abstract getGlobalDebugText(): string | undefined; 146 | 147 | /** 148 | * Determines whether units can be stolen from this mission by other missions with higher priority. 149 | */ 150 | public isUnitsLocked(): boolean { 151 | return true; 152 | } 153 | 154 | abstract getPriority(): number; 155 | } 156 | 157 | export type MissionWithAction = { 158 | mission: Mission; 159 | action: T; 160 | }; 161 | 162 | export type MissionActionNoop = { 163 | type: "noop"; 164 | }; 165 | 166 | export type MissionActionDisband = { 167 | type: "disband"; 168 | reason: any | null; 169 | }; 170 | 171 | export type MissionActionRequestUnits = { 172 | type: "request"; 173 | unitNames: string[]; 174 | priority: number; 175 | }; 176 | 177 | export type MissionActionRequestSpecificUnits = { 178 | type: "requestSpecific"; 179 | unitIds: number[]; 180 | priority: number; 181 | }; 182 | 183 | export type MissionActionGrabFreeCombatants = { 184 | type: "requestCombatants"; 185 | point: Vector2; 186 | radius: number; 187 | }; 188 | 189 | export type MissionActionReleaseUnits = { 190 | type: "releaseUnits"; 191 | unitIds: number[]; 192 | }; 193 | 194 | export const noop = () => 195 | ({ 196 | type: "noop", 197 | }) as MissionActionNoop; 198 | 199 | export const disbandMission = (reason?: any) => ({ type: "disband", reason }) as MissionActionDisband; 200 | export const isDisbandMission = (a: MissionWithAction): a is MissionWithAction => 201 | a.action.type === "disband"; 202 | 203 | export const requestUnits = (unitNames: string[], priority: number) => 204 | ({ type: "request", unitNames, priority }) as MissionActionRequestUnits; 205 | export const isRequestUnits = ( 206 | a: MissionWithAction, 207 | ): a is MissionWithAction => a.action.type === "request"; 208 | 209 | export const requestSpecificUnits = (unitIds: number[], priority: number) => 210 | ({ type: "requestSpecific", unitIds, priority }) as MissionActionRequestSpecificUnits; 211 | export const isRequestSpecificUnits = ( 212 | a: MissionWithAction, 213 | ): a is MissionWithAction => a.action.type === "requestSpecific"; 214 | 215 | export const grabCombatants = (point: Vector2, radius: number) => 216 | ({ type: "requestCombatants", point, radius }) as MissionActionGrabFreeCombatants; 217 | export const isGrabCombatants = ( 218 | a: MissionWithAction, 219 | ): a is MissionWithAction => a.action.type === "requestCombatants"; 220 | 221 | export const releaseUnits = (unitIds: number[]) => ({ type: "releaseUnits", unitIds }) as MissionActionReleaseUnits; 222 | export const isReleaseUnits = ( 223 | a: MissionWithAction, 224 | ): a is MissionWithAction => a.action.type === "releaseUnits"; 225 | 226 | export type MissionAction = 227 | | MissionActionNoop 228 | | MissionActionDisband 229 | | MissionActionRequestUnits 230 | | MissionActionRequestSpecificUnits 231 | | MissionActionGrabFreeCombatants 232 | | MissionActionReleaseUnits; 233 | -------------------------------------------------------------------------------- /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 | private isHostileUnit(unit: UnitData | undefined, hostilePlayerNames: string[]): boolean { 135 | if (!unit) { 136 | return false; 137 | } 138 | 139 | return hostilePlayerNames.includes(unit.owner); 140 | } 141 | 142 | public onGameStart(gameApi: GameApi, playerData: PlayerData) { 143 | this.scoutingManager.onGameStart(gameApi, playerData, this.sectorCache); 144 | } 145 | 146 | onAiUpdate(game: GameApi, playerData: PlayerData): void { 147 | const sectorCache = this.sectorCache; 148 | 149 | sectorCache.updateSectors(game.getCurrentTick(), SECTORS_TO_UPDATE_PER_CYCLE, game.mapApi, playerData); 150 | 151 | this.scoutingManager.onAiUpdate(game, playerData, sectorCache); 152 | 153 | let updateRatio = sectorCache?.getSectorUpdateRatio(game.getCurrentTick() - game.getTickRate() * 60); 154 | if (updateRatio && updateRatio < 1.0) { 155 | this.logger(`${updateRatio * 100.0}% of sectors updated in last 60 seconds.`); 156 | } 157 | 158 | const hostilePlayerNames = game 159 | .getPlayers() 160 | .map((name) => game.getPlayerData(name)) 161 | .filter( 162 | (other) => 163 | other.name !== playerData.name && 164 | other.isCombatant && 165 | !game.areAlliedPlayers(playerData.name, other.name), 166 | ) 167 | .map((other) => other.name); 168 | 169 | // Build the quadtree, if this is too slow we should consider doing this periodically. 170 | const hostileUnitIds = game.getVisibleUnits(playerData.name, "enemy"); 171 | try { 172 | const hostileUnits = hostileUnitIds 173 | .map((id) => game.getGameObjectData(id)) 174 | .filter( 175 | (gameObjectData: GameObjectData | undefined): gameObjectData is GameObjectData => 176 | gameObjectData !== undefined, 177 | ); 178 | 179 | rebuildQuadtree(this.hostileQuadTree, hostileUnits); 180 | } catch (err) { 181 | // Hack. Will be fixed soon. 182 | console.error(`caught error`, hostileUnitIds); 183 | } 184 | 185 | if (game.getCurrentTick() % THREAT_UPDATE_INTERVAL_TICKS == 0) { 186 | let visibility = sectorCache?.getOverallVisibility(); 187 | if (visibility) { 188 | this.logger(`${Math.round(visibility * 1000.0) / 10}% of tiles visible. Calculating threat.`); 189 | // Update the global threat cache 190 | this.threatCache = calculateGlobalThreat(game, playerData, visibility); 191 | 192 | // As the game approaches 2 hours, be more willing to attack. (15 ticks per second) 193 | const gameLengthFactor = Math.max(0, 1.0 - game.getCurrentTick() / (15 * 7200.0)); 194 | this.logger(`Game length multiplier: ${gameLengthFactor}`); 195 | 196 | if (!this._shouldAttack) { 197 | // If not attacking, make it harder to switch to attack mode by multiplying the opponent's threat. 198 | this._shouldAttack = this.checkShouldAttack(this.threatCache, 1.25 * gameLengthFactor); 199 | if (this._shouldAttack) { 200 | this.logger(`Globally switched to attack mode.`); 201 | } 202 | } else { 203 | // If currently attacking, make it harder to switch to defence mode my dampening the opponent's threat. 204 | this._shouldAttack = this.checkShouldAttack(this.threatCache, 0.75 * gameLengthFactor); 205 | if (!this._shouldAttack) { 206 | this.logger(`Globally switched to defence mode.`); 207 | } 208 | } 209 | } 210 | } 211 | 212 | // Update rally point every few ticks. 213 | if (game.getCurrentTick() % RALLY_POINT_UPDATE_INTERVAL_TICKS === 0) { 214 | const enemyPlayers = game 215 | .getPlayers() 216 | .filter((p) => p !== playerData.name && !game.areAlliedPlayers(playerData.name, p)); 217 | const enemy = game.getPlayerData(enemyPlayers[0]); 218 | this.mainRallyPoint = getPointTowardsOtherPoint( 219 | game, 220 | playerData.startLocation, 221 | enemy.startLocation, 222 | 10, 223 | 10, 224 | 0, 225 | ); 226 | } 227 | } 228 | 229 | public getGlobalDebugText(): string | undefined { 230 | if (!this.threatCache) { 231 | return undefined; 232 | } 233 | return ( 234 | `Threat LAND: Them ${Math.round(this.threatCache.totalOffensiveLandThreat)}, us: ${Math.round( 235 | this.threatCache.totalAvailableAntiGroundFirepower, 236 | )}.\n` + 237 | `Threat DEFENSIVE: Them ${Math.round(this.threatCache.totalDefensiveThreat)}, us: ${Math.round( 238 | this.threatCache.totalDefensivePower, 239 | )}.\n` + 240 | `Threat AIR: Them ${Math.round(this.threatCache.totalOffensiveAirThreat)}, us: ${Math.round( 241 | this.threatCache.totalAvailableAntiAirFirepower, 242 | )}.` 243 | ); 244 | } 245 | } 246 | -------------------------------------------------------------------------------- /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/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 | export class QueueController { 55 | private queueStates: QueueState[] = []; 56 | 57 | constructor() {} 58 | 59 | public onAiUpdate( 60 | game: GameApi, 61 | productionApi: ProductionApi, 62 | actionsApi: ActionsApi, 63 | playerData: PlayerData, 64 | threatCache: GlobalThreat | null, 65 | unitTypeRequests: Map, 66 | logger: (message: string) => void, 67 | ) { 68 | this.queueStates = QUEUES.map((queueType) => { 69 | const options = productionApi.getAvailableObjects(queueType); 70 | const items = this.getPrioritiesForBuildingOptions( 71 | game, 72 | options, 73 | threatCache, 74 | playerData, 75 | unitTypeRequests, 76 | logger, 77 | ); 78 | const topItem = items.length > 0 ? items[items.length - 1] : undefined; 79 | return { 80 | queue: queueType, 81 | items, 82 | // only if the top item has a priority above zero 83 | topItem: topItem && topItem.priority > 0 ? topItem : undefined, 84 | }; 85 | }); 86 | const totalWeightAcrossQueues = this.queueStates 87 | .map((decision) => decision.topItem?.priority!) 88 | .reduce((pV, cV) => pV + cV, 0); 89 | const totalCostAcrossQueues = this.queueStates 90 | .map((decision) => decision.topItem?.unit.cost!) 91 | .reduce((pV, cV) => pV + cV, 0); 92 | 93 | this.queueStates.forEach((decision) => { 94 | this.updateBuildQueue( 95 | game, 96 | productionApi, 97 | actionsApi, 98 | playerData, 99 | threatCache, 100 | decision.queue, 101 | decision.topItem, 102 | totalWeightAcrossQueues, 103 | totalCostAcrossQueues, 104 | logger, 105 | ); 106 | }); 107 | 108 | // Repair is simple - just repair everything that's damaged. 109 | if (playerData.credits > 0) { 110 | game.getVisibleUnits(playerData.name, "self", (r) => r.repairable).forEach((unitId) => { 111 | const unit = game.getUnitData(unitId); 112 | if (!unit || !unit.hitPoints || !unit.maxHitPoints || unit.hasWrenchRepair) { 113 | return; 114 | } 115 | if (unit.hitPoints < unit.maxHitPoints) { 116 | actionsApi.toggleRepairWrench(unitId); 117 | } 118 | }); 119 | } 120 | } 121 | 122 | private updateBuildQueue( 123 | game: GameApi, 124 | productionApi: ProductionApi, 125 | actionsApi: ActionsApi, 126 | playerData: PlayerData, 127 | threatCache: GlobalThreat | null, 128 | queueType: QueueType, 129 | decision: TechnoRulesWithPriority | undefined, 130 | totalWeightAcrossQueues: number, 131 | totalCostAcrossQueues: number, 132 | logger: (message: string) => void, 133 | ): void { 134 | const myCredits = playerData.credits; 135 | 136 | const queueData = productionApi.getQueueData(queueType); 137 | if (queueData.status == QueueStatus.Idle) { 138 | // Start building the decided item. 139 | if (decision !== undefined) { 140 | logger(`Decision (${queueTypeToName(queueType)}): ${decision.unit.name}`); 141 | actionsApi.queueForProduction(queueType, decision.unit.name, decision.unit.type, 1); 142 | } 143 | } else if (queueData.status == QueueStatus.Ready && queueData.items.length > 0) { 144 | // Consider placing it. 145 | const objectReady: TechnoRules = queueData.items[0].rules; 146 | if (queueType == QueueType.Structures || queueType == QueueType.Armory) { 147 | let location: { rx: number; ry: number } | undefined = this.getBestLocationForStructure( 148 | game, 149 | playerData, 150 | objectReady, 151 | ); 152 | if (location !== undefined) { 153 | logger( 154 | `Completed: ${queueTypeToName(queueType)}: ${objectReady.name}, placing at ${location.rx},${ 155 | location.ry 156 | }`, 157 | ); 158 | actionsApi.placeBuilding(objectReady.name, location.rx, location.ry); 159 | } else { 160 | logger(`Completed: ${queueTypeToName(queueType)}: ${objectReady.name} but nowhere to place it`); 161 | } 162 | } 163 | } else if (queueData.status == QueueStatus.Active && queueData.items.length > 0 && decision != null) { 164 | // Consider cancelling if something else is significantly higher priority than what is currently being produced. 165 | const currentProduction = queueData.items[0].rules; 166 | if (decision.unit != currentProduction) { 167 | // Changing our mind. 168 | let currentItemPriority = this.getPriorityForBuildingOption( 169 | currentProduction, 170 | game, 171 | playerData, 172 | threatCache, 173 | ); 174 | let newItemPriority = decision.priority; 175 | if (newItemPriority > currentItemPriority * 2) { 176 | logger( 177 | `Dequeueing queue ${queueTypeToName(queueData.type)} unit ${currentProduction.name} because ${ 178 | decision.unit.name 179 | } has 2x higher priority.`, 180 | ); 181 | actionsApi.unqueueFromProduction(queueData.type, currentProduction.name, currentProduction.type, 1); 182 | } 183 | } else { 184 | // Not changing our mind, but maybe other queues are more important for now. 185 | if (totalCostAcrossQueues > myCredits && decision.priority < totalWeightAcrossQueues * 0.25) { 186 | logger( 187 | `Pausing queue ${queueTypeToName(queueData.type)} because weight is low (${ 188 | decision.priority 189 | }/${totalWeightAcrossQueues})`, 190 | ); 191 | actionsApi.pauseProduction(queueData.type); 192 | } 193 | } 194 | } else if (queueData.status == QueueStatus.OnHold) { 195 | // Consider resuming queue if priority is high relative to other queues. 196 | if (myCredits >= totalCostAcrossQueues) { 197 | logger(`Resuming queue ${queueTypeToName(queueData.type)} because credits are high`); 198 | actionsApi.resumeProduction(queueData.type); 199 | } else if (decision && decision.priority >= totalWeightAcrossQueues * 0.25) { 200 | logger( 201 | `Resuming queue ${queueTypeToName(queueData.type)} because weight is high (${ 202 | decision.priority 203 | }/${totalWeightAcrossQueues})`, 204 | ); 205 | actionsApi.resumeProduction(queueData.type); 206 | } 207 | } 208 | } 209 | 210 | private getPrioritiesForBuildingOptions( 211 | game: GameApi, 212 | options: TechnoRules[], 213 | threatCache: GlobalThreat | null, 214 | playerData: PlayerData, 215 | unitTypeRequests: Map, 216 | logger: DebugLogger, 217 | ): TechnoRulesWithPriority[] { 218 | let priorityQueue: TechnoRulesWithPriority[] = []; 219 | options.forEach((option) => { 220 | const calculatedPriority = this.getPriorityForBuildingOption(option, game, playerData, threatCache); 221 | // Get the higher of the dynamic and the mission priority for the unit. 222 | const actualPriority = Math.max( 223 | calculatedPriority, 224 | unitTypeRequests.get(option.name) ?? calculatedPriority, 225 | ); 226 | if (actualPriority > 0) { 227 | priorityQueue.push({ unit: option, priority: actualPriority }); 228 | } 229 | }); 230 | 231 | priorityQueue = priorityQueue.sort((a, b) => a.priority - b.priority); 232 | return priorityQueue; 233 | } 234 | 235 | private getPriorityForBuildingOption( 236 | option: TechnoRules, 237 | game: GameApi, 238 | playerStatus: PlayerData, 239 | threatCache: GlobalThreat | null, 240 | ) { 241 | if (BUILDING_NAME_TO_RULES.has(option.name)) { 242 | let logic = BUILDING_NAME_TO_RULES.get(option.name)!; 243 | return logic.getPriority(game, playerStatus, option, threatCache); 244 | } else { 245 | // Fallback priority when there are no rules. 246 | return ( 247 | DEFAULT_BUILDING_PRIORITY - game.getVisibleUnits(playerStatus.name, "self", (r) => r == option).length 248 | ); 249 | } 250 | } 251 | 252 | private getBestLocationForStructure( 253 | game: GameApi, 254 | playerData: PlayerData, 255 | objectReady: TechnoRules, 256 | ): { rx: number; ry: number } | undefined { 257 | if (BUILDING_NAME_TO_RULES.has(objectReady.name)) { 258 | let logic = BUILDING_NAME_TO_RULES.get(objectReady.name)!; 259 | return logic.getPlacementLocation(game, playerData, objectReady); 260 | } else { 261 | // fallback placement logic 262 | return getDefaultPlacementLocation(game, playerData, playerData.startLocation, objectReady); 263 | } 264 | } 265 | 266 | public getGlobalDebugText(gameApi: GameApi, productionApi: ProductionApi) { 267 | const productionState = QUEUES.reduce((prev, queueType) => { 268 | if (productionApi.getQueueData(queueType).size === 0) { 269 | return prev; 270 | } 271 | const paused = productionApi.getQueueData(queueType).status === QueueStatus.OnHold; 272 | return ( 273 | prev + 274 | " [" + 275 | queueTypeToName(queueType) + 276 | (paused ? " PAUSED" : "") + 277 | ": " + 278 | productionApi 279 | .getQueueData(queueType) 280 | .items.map((item) => item.rules.name + (item.quantity > 1 ? "x" + item.quantity : "")) + 281 | "]" 282 | ); 283 | }, ""); 284 | 285 | const queueStates = this.queueStates 286 | .filter((queueState) => queueState.items.length > 0) 287 | .map((queueState) => { 288 | const queueString = queueState.items 289 | .map((item) => item.unit.name + "(" + Math.round(item.priority * 10) / 10 + ")") 290 | .join(", "); 291 | return `${queueTypeToName(queueState.queue)} Prios: ${queueString}\n`; 292 | }) 293 | .join(""); 294 | 295 | return `Production: ${productionState}\n${queueStates}`; 296 | } 297 | } 298 | -------------------------------------------------------------------------------- /src/bot/logic/mission/missions/attackMission.ts: -------------------------------------------------------------------------------- 1 | import { ActionsApi, GameApi, ObjectType, PlayerData, SideType, UnitData, Vector2 } from "@chronodivide/game-api"; 2 | import { CombatSquad } from "./squads/combatSquad.js"; 3 | import { Mission, MissionAction, disbandMission, noop, requestUnits } from "../mission.js"; 4 | import { MissionFactory } from "../missionFactories.js"; 5 | import { MatchAwareness } from "../../awareness.js"; 6 | import { MissionController } from "../missionController.js"; 7 | import { RetreatMission } from "./retreatMission.js"; 8 | import { DebugLogger, countBy, isOwnedByNeutral, maxBy } from "../../common/utils.js"; 9 | import { ActionBatcher } from "../actionBatcher.js"; 10 | import { getSovietComposition } from "../../composition/sovietCompositions.js"; 11 | import { getAlliedCompositions } from "../../composition/alliedCompositions.js"; 12 | import { UnitComposition } from "../../composition/common.js"; 13 | import { manageMoveMicro } from "./squads/common.js"; 14 | 15 | export enum AttackFailReason { 16 | NoTargets = 0, 17 | DefenceTooStrong = 1, 18 | } 19 | 20 | enum AttackMissionState { 21 | Preparing = 0, 22 | Attacking = 1, 23 | Retreating = 2, 24 | } 25 | 26 | const NO_TARGET_RETARGET_TICKS = 450; 27 | const NO_TARGET_IDLE_TIMEOUT_TICKS = 900; 28 | 29 | function calculateTargetComposition( 30 | gameApi: GameApi, 31 | playerData: PlayerData, 32 | matchAwareness: MatchAwareness, 33 | ): UnitComposition { 34 | if (!playerData.country) { 35 | throw new Error(`player ${playerData.name} has no country`); 36 | } else if (playerData.country.side === SideType.Nod) { 37 | return getSovietComposition(gameApi, playerData, matchAwareness); 38 | } else { 39 | return getAlliedCompositions(gameApi, playerData, matchAwareness); 40 | } 41 | } 42 | 43 | const ATTACK_MISSION_PRIORITY_RAMP = 1.01; 44 | const ATTACK_MISSION_MAX_PRIORITY = 50; 45 | 46 | /** 47 | * A mission that tries to attack a certain area. 48 | */ 49 | export class AttackMission extends Mission { 50 | private squad: CombatSquad; 51 | 52 | private lastTargetSeenAt = 0; 53 | private hasPickedNewTarget: boolean = false; 54 | 55 | private state: AttackMissionState = AttackMissionState.Preparing; 56 | 57 | constructor( 58 | uniqueName: string, 59 | private priority: number, 60 | rallyArea: Vector2, 61 | private attackArea: Vector2, 62 | private radius: number, 63 | private composition: UnitComposition, 64 | logger: DebugLogger, 65 | ) { 66 | super(uniqueName, logger); 67 | this.squad = new CombatSquad(rallyArea, attackArea, radius); 68 | } 69 | 70 | _onAiUpdate( 71 | gameApi: GameApi, 72 | actionsApi: ActionsApi, 73 | playerData: PlayerData, 74 | matchAwareness: MatchAwareness, 75 | actionBatcher: ActionBatcher, 76 | ): MissionAction { 77 | switch (this.state) { 78 | case AttackMissionState.Preparing: 79 | return this.handlePreparingState(gameApi, actionsApi, playerData, matchAwareness, actionBatcher); 80 | case AttackMissionState.Attacking: 81 | return this.handleAttackingState(gameApi, actionsApi, playerData, matchAwareness, actionBatcher); 82 | case AttackMissionState.Retreating: 83 | return this.handleRetreatingState(gameApi, actionsApi, playerData, matchAwareness, actionBatcher); 84 | } 85 | } 86 | 87 | private handlePreparingState( 88 | gameApi: GameApi, 89 | actionsApi: ActionsApi, 90 | playerData: PlayerData, 91 | matchAwareness: MatchAwareness, 92 | actionBatcher: ActionBatcher, 93 | ) { 94 | const currentComposition: UnitComposition = countBy(this.getUnits(gameApi), (unit) => unit.name); 95 | 96 | const missingUnits = Object.entries(this.composition).filter(([unitType, targetAmount]) => { 97 | return !currentComposition[unitType] || currentComposition[unitType] < targetAmount; 98 | }); 99 | 100 | if (missingUnits.length > 0) { 101 | this.priority = Math.min(this.priority * ATTACK_MISSION_PRIORITY_RAMP, ATTACK_MISSION_MAX_PRIORITY); 102 | return requestUnits( 103 | missingUnits.map(([unitName]) => unitName), 104 | this.priority, 105 | ); 106 | } else { 107 | this.priority = ATTACK_MISSION_INITIAL_PRIORITY; 108 | this.state = AttackMissionState.Attacking; 109 | return noop(); 110 | } 111 | } 112 | 113 | private handleAttackingState( 114 | gameApi: GameApi, 115 | actionsApi: ActionsApi, 116 | playerData: PlayerData, 117 | matchAwareness: MatchAwareness, 118 | actionBatcher: ActionBatcher, 119 | ) { 120 | if (this.getUnitIds().length === 0) { 121 | // TODO: disband directly (we no longer retreat when losing) 122 | this.state = AttackMissionState.Retreating; 123 | return noop(); 124 | } 125 | 126 | const foundTargets = matchAwareness 127 | .getHostilesNearPoint2d(this.attackArea, this.radius) 128 | .map((unit) => gameApi.getUnitData(unit.unitId)) 129 | .filter((unit) => !isOwnedByNeutral(unit)) as UnitData[]; 130 | 131 | const update = this.squad.onAiUpdate( 132 | gameApi, 133 | actionsApi, 134 | actionBatcher, 135 | playerData, 136 | this, 137 | matchAwareness, 138 | this.logger, 139 | ); 140 | 141 | if (update.type !== "noop") { 142 | return update; 143 | } 144 | 145 | if (foundTargets.length > 0) { 146 | this.lastTargetSeenAt = gameApi.getCurrentTick(); 147 | this.hasPickedNewTarget = false; 148 | } else if (gameApi.getCurrentTick() > this.lastTargetSeenAt + NO_TARGET_IDLE_TIMEOUT_TICKS) { 149 | return disbandMission(AttackFailReason.NoTargets); 150 | } else if ( 151 | !this.hasPickedNewTarget && 152 | gameApi.getCurrentTick() > this.lastTargetSeenAt + NO_TARGET_RETARGET_TICKS 153 | ) { 154 | const newTarget = generateTarget(gameApi, playerData, matchAwareness); 155 | if (newTarget) { 156 | this.squad.setAttackArea(newTarget); 157 | this.hasPickedNewTarget = true; 158 | } 159 | } 160 | 161 | return noop(); 162 | } 163 | 164 | private handleRetreatingState( 165 | gameApi: GameApi, 166 | actionsApi: ActionsApi, 167 | playerData: PlayerData, 168 | matchAwareness: MatchAwareness, 169 | actionBatcher: ActionBatcher, 170 | ) { 171 | this.getUnits(gameApi).forEach((unitId) => { 172 | actionBatcher.push(manageMoveMicro(unitId, matchAwareness.getMainRallyPoint())); 173 | }); 174 | return disbandMission(); 175 | } 176 | 177 | public getGlobalDebugText(): string | undefined { 178 | return this.squad.getGlobalDebugText() ?? ""; 179 | } 180 | 181 | public getState() { 182 | return this.state; 183 | } 184 | 185 | // This mission can give up its units while preparing. 186 | public isUnitsLocked(): boolean { 187 | return this.state !== AttackMissionState.Preparing; 188 | } 189 | 190 | public getPriority() { 191 | return this.priority; 192 | } 193 | } 194 | 195 | // Calculates the weight for initiating an attack on the position of a unit or building. 196 | // This is separate from unit micro; the squad will be ordered to attack in the vicinity of the point. 197 | const getTargetWeight: (unitData: UnitData, tryFocusHarvester: boolean) => number = (unitData, tryFocusHarvester) => { 198 | if (tryFocusHarvester && unitData.rules.harvester) { 199 | return 100000; 200 | } else if (unitData.type === ObjectType.Building) { 201 | return unitData.maxHitPoints * 10; 202 | } else { 203 | return unitData.maxHitPoints; 204 | } 205 | }; 206 | 207 | function generateTarget( 208 | gameApi: GameApi, 209 | playerData: PlayerData, 210 | matchAwareness: MatchAwareness, 211 | includeBaseLocations: boolean = false, 212 | ): Vector2 | null { 213 | // Randomly decide between harvester and base. 214 | try { 215 | const tryFocusHarvester = gameApi.generateRandomInt(0, 1) === 0; 216 | const enemyUnits = gameApi 217 | .getVisibleUnits(playerData.name, "enemy") 218 | .map((unitId) => gameApi.getUnitData(unitId)) 219 | .filter((u) => !!u && gameApi.getPlayerData(u.owner).isCombatant) as UnitData[]; 220 | 221 | const maxUnit = maxBy(enemyUnits, (u) => getTargetWeight(u, tryFocusHarvester)); 222 | if (maxUnit) { 223 | return new Vector2(maxUnit.tile.rx, maxUnit.tile.ry); 224 | } 225 | if (includeBaseLocations) { 226 | const mapApi = gameApi.mapApi; 227 | const enemyPlayers = gameApi 228 | .getPlayers() 229 | .map(gameApi.getPlayerData) 230 | .filter((otherPlayer) => !gameApi.areAlliedPlayers(playerData.name, otherPlayer.name)); 231 | 232 | const unexploredEnemyLocations = enemyPlayers.filter((otherPlayer) => { 233 | const tile = mapApi.getTile(otherPlayer.startLocation.x, otherPlayer.startLocation.y); 234 | if (!tile) { 235 | return false; 236 | } 237 | return !mapApi.isVisibleTile(tile, playerData.name); 238 | }); 239 | if (unexploredEnemyLocations.length > 0) { 240 | const idx = gameApi.generateRandomInt(0, unexploredEnemyLocations.length - 1); 241 | return unexploredEnemyLocations[idx].startLocation; 242 | } 243 | } 244 | } catch (err) { 245 | // There's a crash here when accessing a building that got destroyed. Will catch and ignore or now. 246 | return null; 247 | } 248 | return null; 249 | } 250 | 251 | // Number of ticks between attacking visible targets. 252 | const VISIBLE_TARGET_ATTACK_COOLDOWN_TICKS = 120; 253 | 254 | // Number of ticks between attacking "bases" (enemy starting locations). 255 | const BASE_ATTACK_COOLDOWN_TICKS = 1800; 256 | 257 | const ATTACK_MISSION_INITIAL_PRIORITY = 1; 258 | 259 | export class AttackMissionFactory implements MissionFactory { 260 | constructor(private lastAttackAt: number = -VISIBLE_TARGET_ATTACK_COOLDOWN_TICKS) {} 261 | 262 | getName(): string { 263 | return "AttackMissionFactory"; 264 | } 265 | 266 | maybeCreateMissions( 267 | gameApi: GameApi, 268 | playerData: PlayerData, 269 | matchAwareness: MatchAwareness, 270 | missionController: MissionController, 271 | logger: DebugLogger, 272 | ): void { 273 | if (gameApi.getCurrentTick() < this.lastAttackAt + VISIBLE_TARGET_ATTACK_COOLDOWN_TICKS) { 274 | return; 275 | } 276 | 277 | // can only have one attack 'preparing' at once. 278 | if ( 279 | missionController 280 | .getMissions() 281 | .some( 282 | (mission): mission is AttackMission => 283 | mission instanceof AttackMission && mission.getState() === AttackMissionState.Preparing, 284 | ) 285 | ) { 286 | return; 287 | } 288 | 289 | const attackRadius = 10; 290 | 291 | const includeEnemyBases = gameApi.getCurrentTick() > this.lastAttackAt + BASE_ATTACK_COOLDOWN_TICKS; 292 | 293 | const attackArea = generateTarget(gameApi, playerData, matchAwareness, includeEnemyBases); 294 | 295 | if (!attackArea) { 296 | return; 297 | } 298 | 299 | const squadName = "attack_" + gameApi.getCurrentTick(); 300 | 301 | const composition: UnitComposition = calculateTargetComposition(gameApi, playerData, matchAwareness); 302 | 303 | const tryAttack = missionController.addMission( 304 | new AttackMission( 305 | squadName, 306 | ATTACK_MISSION_INITIAL_PRIORITY, 307 | matchAwareness.getMainRallyPoint(), 308 | attackArea, 309 | attackRadius, 310 | composition, 311 | logger, 312 | ).then((unitIds, reason) => { 313 | missionController.addMission( 314 | new RetreatMission( 315 | "retreat-from-" + squadName + gameApi.getCurrentTick(), 316 | matchAwareness.getMainRallyPoint(), 317 | unitIds, 318 | logger, 319 | ), 320 | ); 321 | }), 322 | ); 323 | if (tryAttack) { 324 | this.lastAttackAt = gameApi.getCurrentTick(); 325 | } 326 | } 327 | 328 | onMissionFailed( 329 | gameApi: GameApi, 330 | playerData: PlayerData, 331 | matchAwareness: MatchAwareness, 332 | failedMission: Mission, 333 | failureReason: any, 334 | missionController: MissionController, 335 | ): void {} 336 | } 337 | -------------------------------------------------------------------------------- /src/bot/logic/mission/missionController.ts: -------------------------------------------------------------------------------- 1 | // Meta-controller for forming and controlling missions. 2 | // Missions are groups of zero or more units that aim to accomplish a particular goal. 3 | 4 | import { ActionsApi, GameApi, GameObjectData, ObjectType, PlayerData, UnitData, Vector2 } from "@chronodivide/game-api"; 5 | import { 6 | Mission, 7 | MissionAction, 8 | MissionActionDisband, 9 | MissionActionGrabFreeCombatants, 10 | MissionActionReleaseUnits, 11 | MissionActionRequestSpecificUnits, 12 | MissionActionRequestUnits, 13 | MissionWithAction, 14 | isDisbandMission, 15 | isGrabCombatants, 16 | isReleaseUnits, 17 | isRequestSpecificUnits, 18 | isRequestUnits, 19 | } from "./mission.js"; 20 | import { MatchAwareness } from "../awareness.js"; 21 | import { MissionFactory, createMissionFactories } from "./missionFactories.js"; 22 | import { ActionBatcher } from "./actionBatcher.js"; 23 | import { countBy, isSelectableCombatant } from "../common/utils.js"; 24 | import { Squad } from "./missions/squads/squad.js"; 25 | 26 | // `missingUnitTypes` priority decays by this much every update loop. 27 | const MISSING_UNIT_TYPE_REQUEST_DECAY_MULT_RATE = 0.75; 28 | const MISSING_UNIT_TYPE_REQUEST_DECAY_FLAT_RATE = 1; 29 | 30 | export class MissionController { 31 | private missionFactories: MissionFactory[]; 32 | private missions: Mission[] = []; 33 | 34 | // A mapping of unit IDs to the missions they are assigned to. This may contain units that are dead, but 35 | // is periodically cleaned in the update loop. 36 | private unitIdToMission: Map> = new Map(); 37 | 38 | // A mapping of unit types to the highest priority requested for a mission. 39 | // This decays over time if requests are not 'refreshed' by mission. 40 | private requestedUnitTypes: Map = new Map(); 41 | 42 | // Tracks missions to be externally disbanded the next time the mission update loop occurs. 43 | private forceDisbandedMissions: string[] = []; 44 | 45 | constructor(private logger: (message: string, sayInGame?: boolean) => void) { 46 | this.missionFactories = createMissionFactories(); 47 | } 48 | 49 | private updateUnitIds(gameApi: GameApi) { 50 | // Check for units in multiple missions, this shouldn't happen. 51 | this.unitIdToMission = new Map(); 52 | this.missions.forEach((mission) => { 53 | const toRemove: number[] = []; 54 | mission.getUnitIds().forEach((unitId) => { 55 | if (this.unitIdToMission.has(unitId)) { 56 | this.logger(`WARNING: unit ${unitId} is in multiple missions, please debug.`); 57 | } else if (!gameApi.getGameObjectData(unitId)) { 58 | // say, if a unit was killed 59 | toRemove.push(unitId); 60 | } else { 61 | this.unitIdToMission.set(unitId, mission); 62 | } 63 | }); 64 | toRemove.forEach((unitId) => mission.removeUnit(unitId)); 65 | }); 66 | } 67 | 68 | public onAiUpdate( 69 | gameApi: GameApi, 70 | actionsApi: ActionsApi, 71 | playerData: PlayerData, 72 | matchAwareness: MatchAwareness, 73 | ) { 74 | // Remove inactive missions. 75 | this.missions = this.missions.filter((missions) => missions.isActive()); 76 | 77 | this.updateUnitIds(gameApi); 78 | 79 | // Batch actions to reduce spamming of actions for larger armies. 80 | const actionBatcher = new ActionBatcher(); 81 | 82 | // Poll missions for requested actions. 83 | const missionActions: MissionWithAction[] = this.missions.map((mission) => ({ 84 | mission, 85 | action: mission.onAiUpdate(gameApi, actionsApi, playerData, matchAwareness, actionBatcher), 86 | })); 87 | 88 | // Handle disbands and merges. 89 | const disbandedMissions: Map = new Map(); 90 | const disbandedMissionsArray: { mission: Mission; reason: any }[] = []; 91 | this.forceDisbandedMissions.forEach((name) => disbandedMissions.set(name, null)); 92 | this.forceDisbandedMissions = []; 93 | missionActions.filter(isDisbandMission).forEach((a) => { 94 | this.logger(`Mission ${a.mission.getUniqueName()} disbanding as requested.`); 95 | a.mission.getUnitIds().forEach((unitId) => { 96 | this.unitIdToMission.delete(unitId); 97 | actionsApi.setUnitDebugText(unitId, undefined); 98 | }); 99 | disbandedMissions.set(a.mission.getUniqueName(), (a.action as MissionActionDisband).reason); 100 | }); 101 | 102 | // Handle unit requests. 103 | 104 | // Release units 105 | missionActions.filter(isReleaseUnits).forEach((a) => { 106 | a.action.unitIds.forEach((unitId) => { 107 | if (this.unitIdToMission.get(unitId)?.getUniqueName() === a.mission.getUniqueName()) { 108 | this.removeUnitFromMission(a.mission, unitId, actionsApi); 109 | } 110 | }); 111 | }); 112 | 113 | // Request specific units by ID 114 | const unitIdToHighestRequest = missionActions.filter(isRequestSpecificUnits).reduce( 115 | (prev, missionWithAction) => { 116 | const { unitIds } = missionWithAction.action; 117 | unitIds.forEach((unitId) => { 118 | if (prev.hasOwnProperty(unitId)) { 119 | if (prev[unitId].action.priority > prev[unitId].action.priority) { 120 | prev[unitId] = missionWithAction; 121 | } 122 | } else { 123 | prev[unitId] = missionWithAction; 124 | } 125 | }); 126 | return prev; 127 | }, 128 | {} as Record>, 129 | ); 130 | 131 | // Map of Mission ID to Unit Type to Count. 132 | const newMissionAssignments = Object.entries(unitIdToHighestRequest) 133 | .flatMap(([id, request]) => { 134 | const unitId = Number.parseInt(id); 135 | const unit = gameApi.getGameObjectData(unitId); 136 | const { mission: requestingMission } = request; 137 | const missionName = requestingMission.getUniqueName(); 138 | if (!unit) { 139 | this.logger(`mission ${missionName} requested non-existent unit ${unitId}`); 140 | return []; 141 | } 142 | if (!this.unitIdToMission.has(unitId)) { 143 | this.addUnitToMission(requestingMission, unit, actionsApi); 144 | return [{ unitName: unit?.name, mission: requestingMission.getUniqueName() }]; 145 | } 146 | return []; 147 | }) 148 | .reduce( 149 | (acc, curr) => { 150 | if (!acc[curr.mission]) { 151 | acc[curr.mission] = {}; 152 | } 153 | if (!acc[curr.mission][curr.unitName]) { 154 | acc[curr.mission][curr.unitName] = 0; 155 | } 156 | acc[curr.mission][curr.unitName] = acc[curr.mission][curr.unitName] + 1; 157 | return acc; 158 | }, 159 | {} as Record>, 160 | ); 161 | Object.entries(newMissionAssignments).forEach(([mission, assignments]) => { 162 | this.logger( 163 | `Mission ${mission} received: ${Object.entries(assignments) 164 | .map(([unitType, count]) => unitType + " x " + count) 165 | .join(", ")}`, 166 | ); 167 | }); 168 | 169 | // Request units by type - store the highest priority mission for each unit type. 170 | const unitTypeToHighestRequest = missionActions.filter(isRequestUnits).reduce( 171 | (prev, missionWithAction) => { 172 | const { unitNames } = missionWithAction.action; 173 | unitNames.forEach((unitName) => { 174 | if (prev.hasOwnProperty(unitName)) { 175 | if (prev[unitName].action.priority > prev[unitName].action.priority) { 176 | prev[unitName] = missionWithAction; 177 | } 178 | } else { 179 | prev[unitName] = missionWithAction; 180 | } 181 | }); 182 | return prev; 183 | }, 184 | {} as Record>, 185 | ); 186 | 187 | // Request combat-capable units in an area 188 | const grabRequests = missionActions.filter(isGrabCombatants); 189 | 190 | // Find un-assigned units and distribute them among all the requesting missions. 191 | const unitIds = gameApi.getVisibleUnits(playerData.name, "self"); 192 | type UnitWithMission = { 193 | unit: GameObjectData; 194 | mission: Mission | undefined; 195 | }; 196 | // List of units that are unassigned or not in a locked mission. 197 | const freeUnits: UnitWithMission[] = unitIds 198 | .map((unitId) => gameApi.getGameObjectData(unitId)) 199 | .filter((unit): unit is GameObjectData => !!unit) 200 | .map((unit) => ({ 201 | unit, 202 | mission: this.unitIdToMission.get(unit.id), 203 | })) 204 | .filter((unitWithMission) => !unitWithMission.mission || unitWithMission.mission.isUnitsLocked() === false); 205 | 206 | // Sort free units so that unassigned units get chosen before assigned (but unlocked) units. 207 | freeUnits.sort((u1, u2) => (u1.mission?.getPriority() ?? 0) - (u2.mission?.getPriority() ?? 0)); 208 | 209 | type AssignmentWithType = { unitName: string; missionName: string; method: "type" | "grab" }; 210 | const newAssignmentsByType = freeUnits 211 | .flatMap(({ unit: freeUnit, mission: donatingMission }) => { 212 | if (unitTypeToHighestRequest.hasOwnProperty(freeUnit.name)) { 213 | const { mission: requestingMission } = unitTypeToHighestRequest[freeUnit.name]; 214 | if (donatingMission) { 215 | if ( 216 | donatingMission === requestingMission || 217 | donatingMission.getPriority() > requestingMission.getPriority() 218 | ) { 219 | return []; 220 | } 221 | this.removeUnitFromMission(donatingMission, freeUnit.id, actionsApi); 222 | } 223 | this.logger( 224 | `granting unit ${freeUnit.id}#${freeUnit.name} to mission ${requestingMission.getUniqueName()}`, 225 | ); 226 | this.addUnitToMission(requestingMission, freeUnit, actionsApi); 227 | delete unitTypeToHighestRequest[freeUnit.name]; 228 | return [ 229 | { unitName: freeUnit.name, missionName: requestingMission.getUniqueName(), method: "type" }, 230 | ] as AssignmentWithType[]; 231 | } else if (grabRequests.length > 0) { 232 | const grantedMission = grabRequests.find((request) => { 233 | const canGrabUnit = isSelectableCombatant(freeUnit); 234 | return ( 235 | canGrabUnit && 236 | request.action.point.distanceTo(new Vector2(freeUnit.tile.rx, freeUnit.tile.ry)) <= 237 | request.action.radius 238 | ); 239 | }); 240 | if (grantedMission) { 241 | if (donatingMission) { 242 | if ( 243 | donatingMission === grantedMission.mission || 244 | donatingMission.getPriority() > grantedMission.mission.getPriority() 245 | ) { 246 | return []; 247 | } 248 | this.removeUnitFromMission(donatingMission, freeUnit.id, actionsApi); 249 | } 250 | this.addUnitToMission(grantedMission.mission, freeUnit, actionsApi); 251 | return [ 252 | { 253 | unitName: freeUnit.name, 254 | missionName: grantedMission.mission.getUniqueName(), 255 | method: "grab", 256 | }, 257 | ] as AssignmentWithType[]; 258 | } 259 | } 260 | return []; 261 | }) 262 | .reduce( 263 | (acc, curr) => { 264 | if (!acc[curr.missionName]) { 265 | acc[curr.missionName] = {}; 266 | } 267 | if (!acc[curr.missionName][curr.unitName]) { 268 | acc[curr.missionName][curr.unitName] = { grab: 0, type: 0 }; 269 | } 270 | acc[curr.missionName][curr.unitName][curr.method] = 271 | acc[curr.missionName][curr.unitName][curr.method] + 1; 272 | return acc; 273 | }, 274 | {} as Record>>, 275 | ); 276 | Object.entries(newAssignmentsByType).forEach(([mission, assignments]) => { 277 | this.logger( 278 | `Mission ${mission} received: ${Object.entries(assignments) 279 | .flatMap(([unitType, methodToCount]) => 280 | Object.entries(methodToCount) 281 | .filter(([, count]) => count > 0) 282 | .map(([method, count]) => unitType + " x " + count + " (by " + method + ")"), 283 | ) 284 | .join(", ")}`, 285 | ); 286 | }); 287 | 288 | this.updateRequestedUnitTypes(unitTypeToHighestRequest); 289 | 290 | // Send all actions that can be batched together. 291 | actionBatcher.resolve(actionsApi); 292 | 293 | // Remove disbanded and merged missions. 294 | this.missions 295 | .filter((missions) => disbandedMissions.has(missions.getUniqueName())) 296 | .forEach((disbandedMission) => { 297 | const reason = disbandedMissions.get(disbandedMission.getUniqueName()); 298 | this.logger(`mission disbanded: ${disbandedMission.getUniqueName()}, reason: ${reason}`); 299 | disbandedMissionsArray.push({ mission: disbandedMission, reason }); 300 | disbandedMission.endMission(disbandedMissions.get(disbandedMission.getUniqueName())); 301 | }); 302 | this.missions = this.missions.filter((missions) => !disbandedMissions.has(missions.getUniqueName())); 303 | 304 | // Create dynamic missions. 305 | this.missionFactories.forEach((missionFactory) => { 306 | missionFactory.maybeCreateMissions(gameApi, playerData, matchAwareness, this, this.logger); 307 | disbandedMissionsArray.forEach(({ reason, mission }) => { 308 | missionFactory.onMissionFailed(gameApi, playerData, matchAwareness, mission, reason, this, this.logger); 309 | }); 310 | }); 311 | } 312 | 313 | private updateRequestedUnitTypes( 314 | missingUnitTypeToHighestRequest: Record>, 315 | ) { 316 | // Decay the priority over time. 317 | const currentUnitTypes = Array.from(this.requestedUnitTypes.keys()); 318 | for (const unitType of currentUnitTypes) { 319 | const newPriority = 320 | this.requestedUnitTypes.get(unitType)! * MISSING_UNIT_TYPE_REQUEST_DECAY_MULT_RATE - 321 | MISSING_UNIT_TYPE_REQUEST_DECAY_FLAT_RATE; 322 | if (newPriority > 0.5) { 323 | this.requestedUnitTypes.set(unitType, newPriority); 324 | } else { 325 | this.requestedUnitTypes.delete(unitType); 326 | } 327 | } 328 | // Add the new missing units to the priority set, if the request is higher than the existing value. 329 | Object.entries(missingUnitTypeToHighestRequest).forEach(([unitType, request]) => { 330 | const currentPriority = this.requestedUnitTypes.get(unitType); 331 | this.requestedUnitTypes.set( 332 | unitType, 333 | currentPriority ? Math.max(currentPriority, request.action.priority) : request.action.priority, 334 | ); 335 | }); 336 | } 337 | 338 | /** 339 | * Returns the set of units that have been requested for production by the missions. 340 | * 341 | * @returns A map of unit type to the highest priority for that unit type. 342 | */ 343 | public getRequestedUnitTypes(): Map { 344 | return this.requestedUnitTypes; 345 | } 346 | 347 | private addUnitToMission(mission: Mission, unit: GameObjectData, actionsApi: ActionsApi) { 348 | mission.addUnit(unit.id); 349 | this.unitIdToMission.set(unit.id, mission); 350 | actionsApi.setUnitDebugText(unit.id, mission.getUniqueName() + "_" + unit.id); 351 | } 352 | 353 | private removeUnitFromMission(mission: Mission, unitId: number, actionsApi: ActionsApi) { 354 | mission.removeUnit(unitId); 355 | this.unitIdToMission.delete(unitId); 356 | actionsApi.setUnitDebugText(unitId, undefined); 357 | } 358 | 359 | /** 360 | * Attempts to add a mission to the active set. 361 | * @param mission 362 | * @returns The mission if it was accepted, or null if it was not. 363 | */ 364 | public addMission(mission: Mission): Mission | null { 365 | if (this.missions.some((m) => m.getUniqueName() === mission.getUniqueName())) { 366 | // reject non-unique mission names 367 | return null; 368 | } 369 | this.logger(`Added mission: ${mission.getUniqueName()}`); 370 | this.missions.push(mission); 371 | return mission; 372 | } 373 | 374 | /** 375 | * Disband the provided mission on the next possible opportunity. 376 | */ 377 | public disbandMission(missionName: string) { 378 | this.forceDisbandedMissions.push(missionName); 379 | } 380 | 381 | // return text to display for global debug 382 | public getGlobalDebugText(gameApi: GameApi): string { 383 | const unitsInMission = (unitIds: number[]) => 384 | countBy(unitIds, (unitId) => gameApi.getGameObjectData(unitId)?.name); 385 | 386 | let globalDebugText = ""; 387 | 388 | this.missions.forEach((mission) => { 389 | this.logger( 390 | `Mission ${mission.getUniqueName()}: ${Object.entries(unitsInMission(mission.getUnitIds())) 391 | .map(([unitName, count]) => `${unitName} x ${count}`) 392 | .join(", ")}`, 393 | ); 394 | const missionDebugText = mission.getGlobalDebugText(); 395 | if (missionDebugText) { 396 | globalDebugText += mission.getUniqueName() + ": " + missionDebugText + "\n"; 397 | } 398 | }); 399 | return globalDebugText; 400 | } 401 | 402 | public updateDebugText(actionsApi: ActionsApi) { 403 | this.missions.forEach((mission) => { 404 | mission 405 | .getUnitIds() 406 | .forEach((unitId) => actionsApi.setUnitDebugText(unitId, `${unitId}: ${mission.getUniqueName()}`)); 407 | }); 408 | } 409 | 410 | public getMissions() { 411 | return this.missions; 412 | } 413 | } 414 | --------------------------------------------------------------------------------