@supalosa/chronodivide-bot 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -1
- package/TODO.md +0 -3
- package/dist/bot/bot.js +17 -11
- package/dist/bot/bot.js.map +1 -1
- package/dist/bot/logic/building/antiGroundStaticDefence.js +1 -1
- package/dist/bot/logic/building/antiGroundStaticDefence.js.map +1 -1
- package/dist/bot/logic/building/buildingRules.js +52 -45
- package/dist/bot/logic/building/buildingRules.js.map +1 -1
- package/dist/bot/logic/building/queueController.js +7 -2
- package/dist/bot/logic/building/queueController.js.map +1 -1
- package/dist/bot/logic/common/utils.js +14 -0
- package/dist/bot/logic/common/utils.js.map +1 -1
- package/dist/bot/logic/mission/missions/attackMission.js +59 -26
- package/dist/bot/logic/mission/missions/attackMission.js.map +1 -1
- package/dist/bot/logic/squad/behaviours/actionBatcher.js +36 -0
- package/dist/bot/logic/squad/behaviours/actionBatcher.js.map +1 -0
- package/dist/bot/logic/squad/behaviours/combatSquad.js +9 -4
- package/dist/bot/logic/squad/behaviours/combatSquad.js.map +1 -1
- package/dist/bot/logic/squad/behaviours/common.js +7 -9
- package/dist/bot/logic/squad/behaviours/common.js.map +1 -1
- package/dist/bot/logic/squad/behaviours/engineerSquad.js +4 -2
- package/dist/bot/logic/squad/behaviours/engineerSquad.js.map +1 -1
- package/dist/bot/logic/squad/behaviours/expansionSquad.js +4 -2
- package/dist/bot/logic/squad/behaviours/expansionSquad.js.map +1 -1
- package/dist/bot/logic/squad/behaviours/retreatSquad.js +4 -1
- package/dist/bot/logic/squad/behaviours/retreatSquad.js.map +1 -1
- package/dist/bot/logic/squad/behaviours/scoutingSquad.js +5 -2
- package/dist/bot/logic/squad/behaviours/scoutingSquad.js.map +1 -1
- package/dist/bot/logic/squad/squad.js +5 -2
- package/dist/bot/logic/squad/squad.js.map +1 -1
- package/dist/bot/logic/squad/squadBehaviour.js.map +1 -1
- package/dist/bot/logic/squad/squadController.js +37 -37
- package/dist/bot/logic/squad/squadController.js.map +1 -1
- package/dist/exampleBot.js +16 -6
- package/dist/exampleBot.js.map +1 -1
- package/package.json +4 -4
- package/src/bot/bot.ts +24 -13
- package/src/bot/logic/building/antiGroundStaticDefence.ts +1 -1
- package/src/bot/logic/building/buildingRules.ts +58 -48
- package/src/bot/logic/building/queueController.ts +10 -2
- package/src/bot/logic/common/utils.ts +19 -0
- package/src/bot/logic/mission/missions/attackMission.ts +72 -31
- package/src/bot/logic/squad/behaviours/actionBatcher.ts +65 -0
- package/src/bot/logic/squad/behaviours/combatSquad.ts +13 -3
- package/src/bot/logic/squad/behaviours/common.ts +9 -9
- package/src/bot/logic/squad/behaviours/engineerSquad.ts +9 -4
- package/src/bot/logic/squad/behaviours/expansionSquad.ts +9 -4
- package/src/bot/logic/squad/behaviours/retreatSquad.ts +6 -0
- package/src/bot/logic/squad/behaviours/scoutingSquad.ts +6 -0
- package/src/bot/logic/squad/squad.ts +7 -1
- package/src/bot/logic/squad/squadBehaviour.ts +4 -0
- package/src/bot/logic/squad/squadController.ts +19 -2
- package/src/exampleBot.ts +20 -6
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@supalosa/chronodivide-bot",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Example bot for Chrono Divide",
|
|
5
5
|
"repository": "https://github.com/Supalosa/supalosa-chronodivide-bot",
|
|
6
6
|
"main": "dist/exampleBot.js",
|
|
@@ -13,16 +13,16 @@
|
|
|
13
13
|
},
|
|
14
14
|
"license": "UNLICENSED",
|
|
15
15
|
"devDependencies": {
|
|
16
|
-
"@chronodivide/game-api": "^0.
|
|
16
|
+
"@chronodivide/game-api": "^0.49.0",
|
|
17
17
|
"@types/node": "^14.17.32",
|
|
18
18
|
"prettier": "3.0.3",
|
|
19
19
|
"typescript": "^4.3.5"
|
|
20
20
|
},
|
|
21
21
|
"peerDependencies": {
|
|
22
|
-
"@chronodivide/game-api": "^0.
|
|
22
|
+
"@chronodivide/game-api": "^0.49.0"
|
|
23
23
|
},
|
|
24
24
|
"dependencies": {
|
|
25
25
|
"@datastructures-js/priority-queue": "^6.3.0",
|
|
26
|
-
"@timohausmann/quadtree-ts": "
|
|
26
|
+
"@timohausmann/quadtree-ts": "2.2.2"
|
|
27
27
|
}
|
|
28
28
|
}
|
package/src/bot/bot.ts
CHANGED
|
@@ -17,7 +17,7 @@ import { QUEUES, QueueController, queueTypeToName } from "./logic/building/queue
|
|
|
17
17
|
import { MatchAwareness, MatchAwarenessImpl } from "./logic/awareness.js";
|
|
18
18
|
import { formatTimeDuration } from "./logic/common/utils.js";
|
|
19
19
|
|
|
20
|
-
const
|
|
20
|
+
const DEBUG_STATE_UPDATE_INTERVAL_SECONDS = 6;
|
|
21
21
|
|
|
22
22
|
// Number of ticks per second at the base speed.
|
|
23
23
|
const NATURAL_TICK_RATE = 15;
|
|
@@ -33,14 +33,16 @@ export class SupalosaBot extends Bot {
|
|
|
33
33
|
|
|
34
34
|
private matchAwareness: MatchAwareness | null = null;
|
|
35
35
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
36
|
+
constructor(
|
|
37
|
+
name: string,
|
|
38
|
+
country: string,
|
|
39
|
+
private tryAllyWith: string[] = [],
|
|
40
|
+
private enableLogging = true,
|
|
41
|
+
) {
|
|
39
42
|
super(name, country);
|
|
40
43
|
this.missionController = new MissionController((message, sayInGame) => this.logBotStatus(message, sayInGame));
|
|
41
44
|
this.squadController = new SquadController((message, sayInGame) => this.logBotStatus(message, sayInGame));
|
|
42
45
|
this.queueController = new QueueController();
|
|
43
|
-
this.enableLogging = enableLogging;
|
|
44
46
|
}
|
|
45
47
|
|
|
46
48
|
override onGameStart(game: GameApi) {
|
|
@@ -61,6 +63,8 @@ export class SupalosaBot extends Bot {
|
|
|
61
63
|
this.matchAwareness.onGameStart(game, myPlayer);
|
|
62
64
|
|
|
63
65
|
this.logBotStatus(`Map bounds: ${this.knownMapBounds.width}, ${this.knownMapBounds.height}`);
|
|
66
|
+
|
|
67
|
+
this.tryAllyWith.forEach((playerName) => this.actionsApi.toggleAlliance(playerName, true));
|
|
64
68
|
}
|
|
65
69
|
|
|
66
70
|
override onGameTick(game: GameApi) {
|
|
@@ -70,8 +74,8 @@ export class SupalosaBot extends Bot {
|
|
|
70
74
|
|
|
71
75
|
const threatCache = this.matchAwareness.getThreatCache();
|
|
72
76
|
|
|
73
|
-
if ((game.getCurrentTick() / NATURAL_TICK_RATE) %
|
|
74
|
-
this.
|
|
77
|
+
if ((game.getCurrentTick() / NATURAL_TICK_RATE) % DEBUG_STATE_UPDATE_INTERVAL_SECONDS === 0) {
|
|
78
|
+
this.updateDebugState(game);
|
|
75
79
|
}
|
|
76
80
|
|
|
77
81
|
if (game.getCurrentTick() % this.tickRatio! === 0) {
|
|
@@ -138,10 +142,11 @@ export class SupalosaBot extends Bot {
|
|
|
138
142
|
}
|
|
139
143
|
}
|
|
140
144
|
|
|
141
|
-
private
|
|
142
|
-
if (!this.
|
|
145
|
+
private updateDebugState(game: GameApi) {
|
|
146
|
+
if (!this.getDebugMode()) {
|
|
143
147
|
return;
|
|
144
148
|
}
|
|
149
|
+
|
|
145
150
|
const myPlayer = game.getPlayerData(this.name);
|
|
146
151
|
const queueState = QUEUES.reduce((prev, queueType) => {
|
|
147
152
|
if (this.productionApi.getQueueData(queueType).size === 0) {
|
|
@@ -158,12 +163,18 @@ export class SupalosaBot extends Bot {
|
|
|
158
163
|
"]"
|
|
159
164
|
);
|
|
160
165
|
}, "");
|
|
161
|
-
|
|
166
|
+
let globalDebugText = `Cash: ${myPlayer.credits} | Queues: ${queueState}\n`;
|
|
162
167
|
const harvesters = game.getVisibleUnits(this.name, "self", (r) => r.harvester).length;
|
|
163
|
-
|
|
164
|
-
this.squadController.debugSquads(this.gameApi);
|
|
165
|
-
this.logBotStatus(`----- End -----`);
|
|
168
|
+
globalDebugText += `Harvesters: ${harvesters}\n`;
|
|
169
|
+
globalDebugText += this.squadController.debugSquads(this.gameApi, this.actionsApi);
|
|
166
170
|
this.missionController.logDebugOutput();
|
|
171
|
+
|
|
172
|
+
// Tag enemy units with IDs
|
|
173
|
+
game.getVisibleUnits(this.name, "hostile").forEach((unitId) => {
|
|
174
|
+
this.actionsApi.setUnitDebugText(unitId, unitId.toString());
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
this.actionsApi.setGlobalDebugText(globalDebugText);
|
|
167
178
|
}
|
|
168
179
|
|
|
169
180
|
override onGameEvent(ev: ApiEvent) {
|
|
@@ -31,7 +31,7 @@ export class AntiGroundStaticDefence implements AiBuildingRules {
|
|
|
31
31
|
}
|
|
32
32
|
let selectedLocation =
|
|
33
33
|
enemyFacingLocationCandidates[Math.floor(game.generateRandom() * enemyFacingLocationCandidates.length)];
|
|
34
|
-
return getDefaultPlacementLocation(game, playerData, selectedLocation, technoRules, 0);
|
|
34
|
+
return getDefaultPlacementLocation(game, playerData, selectedLocation, technoRules, false, 0);
|
|
35
35
|
}
|
|
36
36
|
|
|
37
37
|
getPriority(
|
|
@@ -2,6 +2,7 @@ import {
|
|
|
2
2
|
BuildingPlacementData,
|
|
3
3
|
GameApi,
|
|
4
4
|
GameMath,
|
|
5
|
+
LandType,
|
|
5
6
|
ObjectType,
|
|
6
7
|
PlayerData,
|
|
7
8
|
Size,
|
|
@@ -51,21 +52,23 @@ export function numBuildingsOwnedOfName(game: GameApi, playerData: PlayerData, n
|
|
|
51
52
|
}
|
|
52
53
|
|
|
53
54
|
/**
|
|
54
|
-
* Computes a rect 'centered' around a structure of a certain size with additional radius.
|
|
55
|
+
* Computes a rect 'centered' around a structure of a certain size with an additional radius (`adjacent`).
|
|
56
|
+
* The radius is optionally expanded by the size of the new building.
|
|
55
57
|
*
|
|
56
|
-
* This is essentially the
|
|
58
|
+
* This is essentially the candidate placement around a given structure.
|
|
57
59
|
*
|
|
58
60
|
* @param point Top-left location of the inner rect.
|
|
59
61
|
* @param t Size of the inner rect.
|
|
60
|
-
* @param adjacent
|
|
62
|
+
* @param adjacent Amount to expand the building's inner rect by (so buildings must be adjacent by this many tiles)
|
|
63
|
+
* @param newBuildingSize? Size of the new building
|
|
61
64
|
* @returns
|
|
62
65
|
*/
|
|
63
|
-
function computeAdjacentRect(point: Vector2, t: Size, adjacent: number) {
|
|
66
|
+
function computeAdjacentRect(point: Vector2, t: Size, adjacent: number, newBuildingSize?: Size) {
|
|
64
67
|
return {
|
|
65
|
-
x: point.x - adjacent,
|
|
66
|
-
y: point.y - adjacent,
|
|
67
|
-
width: t.width + 2 * adjacent,
|
|
68
|
-
height: t.height + 2 * adjacent,
|
|
68
|
+
x: point.x - adjacent - (newBuildingSize?.width || 0),
|
|
69
|
+
y: point.y - adjacent - (newBuildingSize?.height || 0),
|
|
70
|
+
width: t.width + 2 * adjacent + (newBuildingSize?.width || 0),
|
|
71
|
+
height: t.height + 2 * adjacent + (newBuildingSize?.height || 0),
|
|
69
72
|
};
|
|
70
73
|
}
|
|
71
74
|
|
|
@@ -73,8 +76,9 @@ export function getAdjacencyTiles(
|
|
|
73
76
|
game: GameApi,
|
|
74
77
|
playerData: PlayerData,
|
|
75
78
|
technoRules: TechnoRules,
|
|
79
|
+
onWater: boolean,
|
|
76
80
|
minimumSpace: number,
|
|
77
|
-
) {
|
|
81
|
+
): Tile[] {
|
|
78
82
|
const placementRules = game.getBuildingPlacementData(technoRules.name);
|
|
79
83
|
const { width: newBuildingWidth, height: newBuildingHeight } = placementRules.foundation;
|
|
80
84
|
const tiles = [];
|
|
@@ -82,44 +86,48 @@ export function getAdjacencyTiles(
|
|
|
82
86
|
const removedTiles = new Set<string>();
|
|
83
87
|
for (let buildingId of buildings) {
|
|
84
88
|
const building = game.getUnitData(buildingId);
|
|
85
|
-
if (building?.rules?.baseNormal) {
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
89
|
+
if (!building?.rules?.baseNormal) {
|
|
90
|
+
// This building is not considered for adjacency checks.
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
const { foundation, tile } = building;
|
|
94
|
+
const buildingBase = new Vector2(tile.rx, tile.ry);
|
|
95
|
+
const buildingSize = {
|
|
96
|
+
width: foundation?.width,
|
|
97
|
+
height: foundation?.height,
|
|
98
|
+
};
|
|
99
|
+
const range = computeAdjacentRect(buildingBase, buildingSize, technoRules.adjacent, placementRules.foundation);
|
|
100
|
+
const baseTile = game.mapApi.getTile(range.x, range.y);
|
|
101
|
+
if (!baseTile) {
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
const adjacentTiles = game.mapApi
|
|
105
|
+
.getTilesInRect(baseTile, {
|
|
98
106
|
width: range.width,
|
|
99
107
|
height: range.height,
|
|
100
|
-
})
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
108
|
+
})
|
|
109
|
+
.filter((tile) => !onWater || tile.landType === LandType.Water);
|
|
110
|
+
tiles.push(...adjacentTiles);
|
|
111
|
+
|
|
112
|
+
// Prevent placing the new building on tiles that would cause it to overlap with this building.
|
|
113
|
+
const modifiedBase = new Vector2(
|
|
114
|
+
buildingBase.x - (newBuildingWidth - 1),
|
|
115
|
+
buildingBase.y - (newBuildingHeight - 1),
|
|
116
|
+
);
|
|
117
|
+
const modifiedSize = {
|
|
118
|
+
width: buildingSize.width + (newBuildingWidth - 1),
|
|
119
|
+
height: buildingSize.height + (newBuildingHeight - 1),
|
|
120
|
+
};
|
|
121
|
+
const blockedRect = computeAdjacentRect(modifiedBase, modifiedSize, minimumSpace);
|
|
122
|
+
const buildingTiles = adjacentTiles.filter((tile) => {
|
|
123
|
+
return (
|
|
124
|
+
tile.rx >= blockedRect.x &&
|
|
125
|
+
tile.rx < blockedRect.x + blockedRect.width &&
|
|
126
|
+
tile.ry >= blockedRect.y &&
|
|
127
|
+
tile.ry < blockedRect.y + blockedRect.height
|
|
107
128
|
);
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
height: buildingSize.height + (newBuildingHeight - 1),
|
|
111
|
-
};
|
|
112
|
-
const blockedRect = computeAdjacentRect(modifiedBase, modifiedSize, minimumSpace);
|
|
113
|
-
const buildingTiles = adjacentTiles.filter((tile) => {
|
|
114
|
-
return (
|
|
115
|
-
tile.rx >= blockedRect.x &&
|
|
116
|
-
tile.rx < blockedRect.x + blockedRect.width &&
|
|
117
|
-
tile.ry >= blockedRect.y &&
|
|
118
|
-
tile.ry < blockedRect.y + blockedRect.height
|
|
119
|
-
);
|
|
120
|
-
});
|
|
121
|
-
buildingTiles.forEach((buildingTile) => removedTiles.add(buildingTile.id));
|
|
122
|
-
}
|
|
129
|
+
});
|
|
130
|
+
buildingTiles.forEach((buildingTile) => removedTiles.add(buildingTile.id));
|
|
123
131
|
}
|
|
124
132
|
// Remove duplicate tiles.
|
|
125
133
|
const withDuplicatesRemoved = uniqBy(tiles, (tile) => tile.id);
|
|
@@ -151,17 +159,18 @@ function distance(x1: number, y1: number, x2: number, y2: number) {
|
|
|
151
159
|
export function getDefaultPlacementLocation(
|
|
152
160
|
game: GameApi,
|
|
153
161
|
playerData: PlayerData,
|
|
154
|
-
|
|
162
|
+
idealPoint: Vector2,
|
|
155
163
|
technoRules: TechnoRules,
|
|
164
|
+
onWater: boolean = false,
|
|
156
165
|
minSpace: number = 1,
|
|
157
166
|
): { rx: number; ry: number } | undefined {
|
|
158
|
-
//
|
|
167
|
+
// Closest possible location near `startPoint`.
|
|
159
168
|
const size: BuildingPlacementData = game.getBuildingPlacementData(technoRules.name);
|
|
160
169
|
if (!size) {
|
|
161
170
|
return undefined;
|
|
162
171
|
}
|
|
163
|
-
const tiles = getAdjacencyTiles(game, playerData, technoRules, minSpace);
|
|
164
|
-
const tileDistances = getTileDistances(
|
|
172
|
+
const tiles = getAdjacencyTiles(game, playerData, technoRules, onWater, minSpace);
|
|
173
|
+
const tileDistances = getTileDistances(idealPoint, tiles);
|
|
165
174
|
|
|
166
175
|
for (let tileDistance of tileDistances) {
|
|
167
176
|
if (tileDistance.tile && game.canPlaceBuilding(playerData.name, technoRules.name, tileDistance.tile)) {
|
|
@@ -186,6 +195,7 @@ export const BUILDING_NAME_TO_RULES = new Map<string, AiBuildingRules>([
|
|
|
186
195
|
["ENGINEER", new BasicBuilding(10, 1, 1000)], // Engineer
|
|
187
196
|
["GADEPT", new BasicBuilding(1, 1, 10000)], // Repair Depot
|
|
188
197
|
["GAAIRC", new BasicBuilding(10, 1, 500)], // Airforce Command
|
|
198
|
+
["AMRADR", new BasicBuilding(10, 1, 500)], // Airforce Command (USA)
|
|
189
199
|
|
|
190
200
|
["GATECH", new BasicBuilding(20, 1, 4000)], // Allied Battle Lab
|
|
191
201
|
["GAYARD", new BasicBuilding(0, 0, 0)], // Naval Yard, disabled
|
|
@@ -91,7 +91,9 @@ export class QueueController {
|
|
|
91
91
|
if (!unit || !unit.hitPoints || !unit.maxHitPoints || unit.hasWrenchRepair) {
|
|
92
92
|
return;
|
|
93
93
|
}
|
|
94
|
-
|
|
94
|
+
if (unit.hitPoints < unit.maxHitPoints) {
|
|
95
|
+
actionsApi.toggleRepairWrench(unitId);
|
|
96
|
+
}
|
|
95
97
|
});
|
|
96
98
|
}
|
|
97
99
|
|
|
@@ -120,14 +122,20 @@ export class QueueController {
|
|
|
120
122
|
// Consider placing it.
|
|
121
123
|
const objectReady: TechnoRules = queueData.items[0].rules;
|
|
122
124
|
if (queueType == QueueType.Structures || queueType == QueueType.Armory) {
|
|
123
|
-
logger(`Complete ${queueTypeToName(queueType)}: ${objectReady.name}`);
|
|
124
125
|
let location: { rx: number; ry: number } | undefined = this.getBestLocationForStructure(
|
|
125
126
|
game,
|
|
126
127
|
playerData,
|
|
127
128
|
objectReady,
|
|
128
129
|
);
|
|
129
130
|
if (location !== undefined) {
|
|
131
|
+
logger(
|
|
132
|
+
`Completed: ${queueTypeToName(queueType)}: ${objectReady.name}, placing at ${location.rx},${
|
|
133
|
+
location.ry
|
|
134
|
+
}`,
|
|
135
|
+
);
|
|
130
136
|
actionsApi.placeBuilding(objectReady.name, location.rx, location.ry);
|
|
137
|
+
} else {
|
|
138
|
+
logger(`Completed: ${queueTypeToName(queueType)}: ${objectReady.name} but nowhere to place it`);
|
|
131
139
|
}
|
|
132
140
|
}
|
|
133
141
|
} else if (queueData.status == QueueStatus.Active && queueData.items.length > 0 && decision != null) {
|
|
@@ -16,6 +16,8 @@ export function pad(n: any, format = "0000") {
|
|
|
16
16
|
return format.substring(0, format.length - str.length) + str;
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
// So we don't need lodash
|
|
20
|
+
|
|
19
21
|
export function maxBy<T>(array: T[], predicate: (arg: T) => number | null): T | null {
|
|
20
22
|
if (array.length === 0) {
|
|
21
23
|
return null;
|
|
@@ -63,3 +65,20 @@ export function countBy<T>(array: T[], predicate: (arg: T) => string | undefined
|
|
|
63
65
|
{} as Record<string, number>,
|
|
64
66
|
);
|
|
65
67
|
}
|
|
68
|
+
|
|
69
|
+
export function groupBy<K extends string, V>(array: V[], predicate: (arg: V) => K): { [key in K]: V[] } {
|
|
70
|
+
return array.reduce(
|
|
71
|
+
(prev, newVal) => {
|
|
72
|
+
const val = predicate(newVal);
|
|
73
|
+
if (val === undefined) {
|
|
74
|
+
return prev;
|
|
75
|
+
}
|
|
76
|
+
if (!prev.hasOwnProperty(val)) {
|
|
77
|
+
prev[val] = [];
|
|
78
|
+
}
|
|
79
|
+
prev[val].push(newVal);
|
|
80
|
+
return prev;
|
|
81
|
+
},
|
|
82
|
+
{} as Record<K, V[]>,
|
|
83
|
+
);
|
|
84
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { GameApi, ObjectType, PlayerData, UnitData, Vector2 } from "@chronodivide/game-api";
|
|
1
|
+
import { GameApi, GameMath, MapApi, ObjectType, PlayerData, UnitData, Vector2 } from "@chronodivide/game-api";
|
|
2
2
|
import { CombatSquad } from "../../squad/behaviours/combatSquad.js";
|
|
3
3
|
import { Mission, MissionAction, disbandMission, noop } from "../mission.js";
|
|
4
4
|
import { Squad } from "../../squad/squad.js";
|
|
@@ -13,13 +13,16 @@ export enum AttackFailReason {
|
|
|
13
13
|
DefenceTooStrong = 1,
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
-
const
|
|
16
|
+
const NO_TARGET_RETARGET_TICKS = 450;
|
|
17
|
+
const NO_TARGET_IDLE_TIMEOUT_TICKS = 900;
|
|
17
18
|
|
|
18
19
|
/**
|
|
19
20
|
* A mission that tries to attack a certain area.
|
|
20
21
|
*/
|
|
21
22
|
export class AttackMission extends Mission<AttackFailReason> {
|
|
22
23
|
private lastTargetSeenAt = 0;
|
|
24
|
+
private behaviour: CombatSquad | undefined;
|
|
25
|
+
private hasPickedNewTarget: boolean = false;
|
|
23
26
|
|
|
24
27
|
constructor(
|
|
25
28
|
uniqueName: string,
|
|
@@ -34,9 +37,8 @@ export class AttackMission extends Mission<AttackFailReason> {
|
|
|
34
37
|
|
|
35
38
|
onAiUpdate(gameApi: GameApi, playerData: PlayerData, matchAwareness: MatchAwareness): MissionAction {
|
|
36
39
|
if (this.getSquad() === null) {
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
);
|
|
40
|
+
this.behaviour = new CombatSquad(this.rallyArea, this.attackArea, this.radius);
|
|
41
|
+
return this.setSquad(new Squad(this.getUniqueName(), this.behaviour, this));
|
|
40
42
|
} else {
|
|
41
43
|
// Dispatch missions.
|
|
42
44
|
if (!matchAwareness.shouldAttack()) {
|
|
@@ -47,16 +49,24 @@ export class AttackMission extends Mission<AttackFailReason> {
|
|
|
47
49
|
|
|
48
50
|
if (foundTargets.length > 0) {
|
|
49
51
|
this.lastTargetSeenAt = gameApi.getCurrentTick();
|
|
52
|
+
this.hasPickedNewTarget = false;
|
|
50
53
|
} else if (gameApi.getCurrentTick() > this.lastTargetSeenAt + NO_TARGET_IDLE_TIMEOUT_TICKS) {
|
|
51
54
|
return disbandMission(AttackFailReason.NoTargets);
|
|
55
|
+
} else if (
|
|
56
|
+
!this.hasPickedNewTarget &&
|
|
57
|
+
gameApi.getCurrentTick() > this.lastTargetSeenAt + NO_TARGET_RETARGET_TICKS
|
|
58
|
+
) {
|
|
59
|
+
const newTarget = generateTarget(gameApi, playerData, matchAwareness);
|
|
60
|
+
if (newTarget) {
|
|
61
|
+
this.behaviour?.setAttackArea(newTarget);
|
|
62
|
+
this.hasPickedNewTarget = true;
|
|
63
|
+
}
|
|
52
64
|
}
|
|
53
65
|
}
|
|
54
66
|
return noop();
|
|
55
67
|
}
|
|
56
68
|
}
|
|
57
69
|
|
|
58
|
-
const ATTACK_COOLDOWN_TICKS = 120;
|
|
59
|
-
|
|
60
70
|
// Calculates the weight for initiating an attack on the position of a unit or building.
|
|
61
71
|
// This is separate from unit micro; the squad will be ordered to attack in the vicinity of the point.
|
|
62
72
|
const getTargetWeight: (unitData: UnitData, tryFocusHarvester: boolean) => number = (unitData, tryFocusHarvester) => {
|
|
@@ -69,33 +79,63 @@ const getTargetWeight: (unitData: UnitData, tryFocusHarvester: boolean) => numbe
|
|
|
69
79
|
}
|
|
70
80
|
};
|
|
71
81
|
|
|
82
|
+
function generateTarget(
|
|
83
|
+
gameApi: GameApi,
|
|
84
|
+
playerData: PlayerData,
|
|
85
|
+
matchAwareness: MatchAwareness,
|
|
86
|
+
includeBaseLocations: boolean = false,
|
|
87
|
+
): Vector2 | null {
|
|
88
|
+
// Randomly decide between harvester and base.
|
|
89
|
+
try {
|
|
90
|
+
const tryFocusHarvester = gameApi.generateRandomInt(0, 1) === 0;
|
|
91
|
+
const enemyUnits = gameApi
|
|
92
|
+
.getVisibleUnits(playerData.name, "hostile")
|
|
93
|
+
.map((unitId) => gameApi.getUnitData(unitId))
|
|
94
|
+
.filter((u) => !!u && gameApi.getPlayerData(u.owner).isCombatant) as UnitData[];
|
|
95
|
+
|
|
96
|
+
const maxUnit = maxBy(enemyUnits, (u) => getTargetWeight(u, tryFocusHarvester));
|
|
97
|
+
if (maxUnit) {
|
|
98
|
+
return new Vector2(maxUnit.tile.rx, maxUnit.tile.ry);
|
|
99
|
+
}
|
|
100
|
+
if (includeBaseLocations) {
|
|
101
|
+
const mapApi = gameApi.mapApi;
|
|
102
|
+
const enemyPlayers = gameApi
|
|
103
|
+
.getPlayers()
|
|
104
|
+
.map(gameApi.getPlayerData)
|
|
105
|
+
.filter((otherPlayer) => !gameApi.areAlliedPlayers(playerData.name, otherPlayer.name));
|
|
106
|
+
|
|
107
|
+
const unexploredEnemyLocations = enemyPlayers.filter((otherPlayer) => {
|
|
108
|
+
const tile = mapApi.getTile(otherPlayer.startLocation.x, otherPlayer.startLocation.y);
|
|
109
|
+
if (!tile) {
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
112
|
+
return !mapApi.isVisibleTile(tile, playerData.name);
|
|
113
|
+
});
|
|
114
|
+
if (unexploredEnemyLocations.length > 0) {
|
|
115
|
+
const idx = gameApi.generateRandomInt(0, unexploredEnemyLocations.length - 1);
|
|
116
|
+
return unexploredEnemyLocations[idx].startLocation;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
} catch (err) {
|
|
120
|
+
// There's a crash here when accessing a building that got destroyed. Will catch and ignore or now.
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Number of ticks between attacking visible targets.
|
|
127
|
+
const VISIBLE_TARGET_ATTACK_COOLDOWN_TICKS = 120;
|
|
128
|
+
|
|
129
|
+
// Number of ticks between attacking "bases" (enemy starting locations).
|
|
130
|
+
const BASE_ATTACK_COOLDOWN_TICKS = 1800;
|
|
131
|
+
|
|
72
132
|
export class AttackMissionFactory implements MissionFactory {
|
|
73
|
-
constructor(private lastAttackAt: number = -
|
|
133
|
+
constructor(private lastAttackAt: number = -VISIBLE_TARGET_ATTACK_COOLDOWN_TICKS) {}
|
|
74
134
|
|
|
75
135
|
getName(): string {
|
|
76
136
|
return "AttackMissionFactory";
|
|
77
137
|
}
|
|
78
138
|
|
|
79
|
-
generateTarget(gameApi: GameApi, playerData: PlayerData, matchAwareness: MatchAwareness): Vector2 | null {
|
|
80
|
-
// Randomly decide between harvester and base.
|
|
81
|
-
try {
|
|
82
|
-
const tryFocusHarvester = gameApi.generateRandomInt(0, 1) === 0;
|
|
83
|
-
const enemyUnits = gameApi
|
|
84
|
-
.getVisibleUnits(playerData.name, "hostile")
|
|
85
|
-
.map((unitId) => gameApi.getUnitData(unitId))
|
|
86
|
-
.filter((u) => !!u && gameApi.getPlayerData(u.owner).isCombatant) as UnitData[];
|
|
87
|
-
|
|
88
|
-
const maxUnit = maxBy(enemyUnits, (u) => getTargetWeight(u, tryFocusHarvester));
|
|
89
|
-
if (maxUnit) {
|
|
90
|
-
return new Vector2(maxUnit.tile.rx, maxUnit.tile.ry);
|
|
91
|
-
}
|
|
92
|
-
} catch (err) {
|
|
93
|
-
// There's a crash here when accessing a building that got destroyed. Will catch and ignore or now.
|
|
94
|
-
return null;
|
|
95
|
-
}
|
|
96
|
-
return null;
|
|
97
|
-
}
|
|
98
|
-
|
|
99
139
|
maybeCreateMissions(
|
|
100
140
|
gameApi: GameApi,
|
|
101
141
|
playerData: PlayerData,
|
|
@@ -106,16 +146,17 @@ export class AttackMissionFactory implements MissionFactory {
|
|
|
106
146
|
if (!matchAwareness.shouldAttack()) {
|
|
107
147
|
return;
|
|
108
148
|
}
|
|
109
|
-
if (gameApi.getCurrentTick() < this.lastAttackAt +
|
|
149
|
+
if (gameApi.getCurrentTick() < this.lastAttackAt + VISIBLE_TARGET_ATTACK_COOLDOWN_TICKS) {
|
|
110
150
|
return;
|
|
111
151
|
}
|
|
112
152
|
|
|
113
153
|
const attackRadius = 15;
|
|
114
154
|
|
|
115
|
-
const
|
|
155
|
+
const includeEnemyBases = gameApi.getCurrentTick() > this.lastAttackAt + BASE_ATTACK_COOLDOWN_TICKS;
|
|
156
|
+
|
|
157
|
+
const attackArea = generateTarget(gameApi, playerData, matchAwareness, includeEnemyBases);
|
|
116
158
|
|
|
117
159
|
if (!attackArea) {
|
|
118
|
-
// Nothing to attack.
|
|
119
160
|
return;
|
|
120
161
|
}
|
|
121
162
|
|
|
@@ -0,0 +1,65 @@
|
|
|
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 type BatchableAction = {
|
|
8
|
+
unitId: number;
|
|
9
|
+
orderType: OrderType;
|
|
10
|
+
point?: Vector2;
|
|
11
|
+
targetId?: number;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export class ActionBatcher {
|
|
15
|
+
private actions: BatchableAction[];
|
|
16
|
+
|
|
17
|
+
constructor() {
|
|
18
|
+
this.actions = [];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
push(action: BatchableAction) {
|
|
22
|
+
this.actions.push(action);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
resolve(actionsApi: ActionsApi) {
|
|
26
|
+
const groupedCommands = groupBy(this.actions, (action) => action.orderType.valueOf().toString());
|
|
27
|
+
const vectorToStr = (v: Vector2) => v.x + "," + v.y;
|
|
28
|
+
const strToVector = (str: string) => {
|
|
29
|
+
const [x, y] = str.split(",");
|
|
30
|
+
return new Vector2(parseInt(x), parseInt(y));
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
// Group by command type.
|
|
34
|
+
Object.entries(groupedCommands).forEach(([commandValue, commands]) => {
|
|
35
|
+
// i hate this
|
|
36
|
+
const commandType: OrderType = parseInt(commandValue) as OrderType;
|
|
37
|
+
// Group by command target ID.
|
|
38
|
+
const byTarget = groupBy(
|
|
39
|
+
commands.filter((command) => !!command.targetId),
|
|
40
|
+
(command) => command.targetId?.toString()!,
|
|
41
|
+
);
|
|
42
|
+
Object.entries(byTarget).forEach(([targetId, unitCommands]) => {
|
|
43
|
+
actionsApi.orderUnits(
|
|
44
|
+
unitCommands.map((command) => command.unitId),
|
|
45
|
+
commandType,
|
|
46
|
+
parseInt(targetId),
|
|
47
|
+
);
|
|
48
|
+
});
|
|
49
|
+
// Group by position (the vector is encoded as a string of the form "x,y")
|
|
50
|
+
const byPosition = groupBy(
|
|
51
|
+
commands.filter((command) => !!command.point),
|
|
52
|
+
(command) => vectorToStr(command.point!),
|
|
53
|
+
);
|
|
54
|
+
Object.entries(byPosition).forEach(([point, unitCommands]) => {
|
|
55
|
+
const vector = strToVector(point);
|
|
56
|
+
actionsApi.orderUnits(
|
|
57
|
+
unitCommands.map((command) => command.unitId),
|
|
58
|
+
commandType,
|
|
59
|
+
vector.x,
|
|
60
|
+
vector.y,
|
|
61
|
+
);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -4,6 +4,7 @@ import { SquadAction, SquadBehaviour, grabCombatants, noop } from "../squadBehav
|
|
|
4
4
|
import { MatchAwareness } from "../../awareness.js";
|
|
5
5
|
import { getAttackWeight, manageAttackMicro, manageMoveMicro } from "./common.js";
|
|
6
6
|
import { DebugLogger, maxBy } from "../../common/utils.js";
|
|
7
|
+
import { ActionBatcher } from "./actionBatcher.js";
|
|
7
8
|
|
|
8
9
|
const TARGET_UPDATE_INTERVAL_TICKS = 10;
|
|
9
10
|
const GRAB_INTERVAL_TICKS = 10;
|
|
@@ -29,6 +30,8 @@ export class CombatSquad implements SquadBehaviour {
|
|
|
29
30
|
private lastCommand: number | null = null;
|
|
30
31
|
private state = SquadState.Gathering;
|
|
31
32
|
|
|
33
|
+
private debugLastTarget: string | undefined;
|
|
34
|
+
|
|
32
35
|
/**
|
|
33
36
|
*
|
|
34
37
|
* @param rallyArea the initial location to grab combatants
|
|
@@ -41,6 +44,10 @@ export class CombatSquad implements SquadBehaviour {
|
|
|
41
44
|
private radius: number,
|
|
42
45
|
) {}
|
|
43
46
|
|
|
47
|
+
public getGlobalDebugText(): string | undefined {
|
|
48
|
+
return this.debugLastTarget ?? "<none>";
|
|
49
|
+
}
|
|
50
|
+
|
|
44
51
|
public setAttackArea(targetArea: Vector2) {
|
|
45
52
|
this.targetArea = targetArea;
|
|
46
53
|
}
|
|
@@ -48,6 +55,7 @@ export class CombatSquad implements SquadBehaviour {
|
|
|
48
55
|
public onAiUpdate(
|
|
49
56
|
gameApi: GameApi,
|
|
50
57
|
actionsApi: ActionsApi,
|
|
58
|
+
actionBatcher: ActionBatcher,
|
|
51
59
|
playerData: PlayerData,
|
|
52
60
|
squad: Squad,
|
|
53
61
|
matchAwareness: MatchAwareness,
|
|
@@ -81,7 +89,7 @@ export class CombatSquad implements SquadBehaviour {
|
|
|
81
89
|
maxDistance > requiredGatherRadius
|
|
82
90
|
) {
|
|
83
91
|
units.forEach((unit) => {
|
|
84
|
-
manageMoveMicro(
|
|
92
|
+
actionBatcher.push(manageMoveMicro(unit, centerOfMass));
|
|
85
93
|
});
|
|
86
94
|
} else {
|
|
87
95
|
logger(`CombatSquad ${squad.getName()} switching back to attack mode (${maxDistance})`);
|
|
@@ -109,9 +117,11 @@ export class CombatSquad implements SquadBehaviour {
|
|
|
109
117
|
.map(({ unitId }) => gameApi.getUnitData(unitId)) as UnitData[];
|
|
110
118
|
const bestUnit = maxBy(nearbyHostiles, (target) => getAttackWeight(unit, target));
|
|
111
119
|
if (bestUnit) {
|
|
112
|
-
manageAttackMicro(
|
|
120
|
+
actionBatcher.push(manageAttackMicro(unit, bestUnit));
|
|
121
|
+
this.debugLastTarget = `Unit ${bestUnit.id.toString()}`;
|
|
113
122
|
} else {
|
|
114
|
-
manageMoveMicro(
|
|
123
|
+
actionBatcher.push(manageMoveMicro(unit, targetPoint));
|
|
124
|
+
this.debugLastTarget = `@${targetPoint.x},${targetPoint.y}`;
|
|
115
125
|
}
|
|
116
126
|
}
|
|
117
127
|
}
|