@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.
Files changed (53) hide show
  1. package/README.md +12 -1
  2. package/TODO.md +0 -3
  3. package/dist/bot/bot.js +17 -11
  4. package/dist/bot/bot.js.map +1 -1
  5. package/dist/bot/logic/building/antiGroundStaticDefence.js +1 -1
  6. package/dist/bot/logic/building/antiGroundStaticDefence.js.map +1 -1
  7. package/dist/bot/logic/building/buildingRules.js +52 -45
  8. package/dist/bot/logic/building/buildingRules.js.map +1 -1
  9. package/dist/bot/logic/building/queueController.js +7 -2
  10. package/dist/bot/logic/building/queueController.js.map +1 -1
  11. package/dist/bot/logic/common/utils.js +14 -0
  12. package/dist/bot/logic/common/utils.js.map +1 -1
  13. package/dist/bot/logic/mission/missions/attackMission.js +59 -26
  14. package/dist/bot/logic/mission/missions/attackMission.js.map +1 -1
  15. package/dist/bot/logic/squad/behaviours/actionBatcher.js +36 -0
  16. package/dist/bot/logic/squad/behaviours/actionBatcher.js.map +1 -0
  17. package/dist/bot/logic/squad/behaviours/combatSquad.js +9 -4
  18. package/dist/bot/logic/squad/behaviours/combatSquad.js.map +1 -1
  19. package/dist/bot/logic/squad/behaviours/common.js +7 -9
  20. package/dist/bot/logic/squad/behaviours/common.js.map +1 -1
  21. package/dist/bot/logic/squad/behaviours/engineerSquad.js +4 -2
  22. package/dist/bot/logic/squad/behaviours/engineerSquad.js.map +1 -1
  23. package/dist/bot/logic/squad/behaviours/expansionSquad.js +4 -2
  24. package/dist/bot/logic/squad/behaviours/expansionSquad.js.map +1 -1
  25. package/dist/bot/logic/squad/behaviours/retreatSquad.js +4 -1
  26. package/dist/bot/logic/squad/behaviours/retreatSquad.js.map +1 -1
  27. package/dist/bot/logic/squad/behaviours/scoutingSquad.js +5 -2
  28. package/dist/bot/logic/squad/behaviours/scoutingSquad.js.map +1 -1
  29. package/dist/bot/logic/squad/squad.js +5 -2
  30. package/dist/bot/logic/squad/squad.js.map +1 -1
  31. package/dist/bot/logic/squad/squadBehaviour.js.map +1 -1
  32. package/dist/bot/logic/squad/squadController.js +37 -37
  33. package/dist/bot/logic/squad/squadController.js.map +1 -1
  34. package/dist/exampleBot.js +16 -6
  35. package/dist/exampleBot.js.map +1 -1
  36. package/package.json +4 -4
  37. package/src/bot/bot.ts +24 -13
  38. package/src/bot/logic/building/antiGroundStaticDefence.ts +1 -1
  39. package/src/bot/logic/building/buildingRules.ts +58 -48
  40. package/src/bot/logic/building/queueController.ts +10 -2
  41. package/src/bot/logic/common/utils.ts +19 -0
  42. package/src/bot/logic/mission/missions/attackMission.ts +72 -31
  43. package/src/bot/logic/squad/behaviours/actionBatcher.ts +65 -0
  44. package/src/bot/logic/squad/behaviours/combatSquad.ts +13 -3
  45. package/src/bot/logic/squad/behaviours/common.ts +9 -9
  46. package/src/bot/logic/squad/behaviours/engineerSquad.ts +9 -4
  47. package/src/bot/logic/squad/behaviours/expansionSquad.ts +9 -4
  48. package/src/bot/logic/squad/behaviours/retreatSquad.ts +6 -0
  49. package/src/bot/logic/squad/behaviours/scoutingSquad.ts +6 -0
  50. package/src/bot/logic/squad/squad.ts +7 -1
  51. package/src/bot/logic/squad/squadBehaviour.ts +4 -0
  52. package/src/bot/logic/squad/squadController.ts +19 -2
  53. 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.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.46.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.46.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": "^2.0.0-beta.1"
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 DEBUG_TIMESTAMP_OUTPUT_INTERVAL_SECONDS = 60;
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
- private enableLogging: boolean;
37
-
38
- constructor(name: string, country: string, enableLogging = true) {
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) % DEBUG_TIMESTAMP_OUTPUT_INTERVAL_SECONDS === 0) {
74
- this.logDebugState(game);
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 logDebugState(game: GameApi) {
142
- if (!this.enableLogging) {
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
- this.logBotStatus(`----- Cash: ${myPlayer.credits} ----- | Queues: ${queueState}`);
166
+ let globalDebugText = `Cash: ${myPlayer.credits} | Queues: ${queueState}\n`;
162
167
  const harvesters = game.getVisibleUnits(this.name, "self", (r) => r.harvester).length;
163
- this.logBotStatus(`Harvesters: ${harvesters}`);
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 placeable area around a given structure.
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 Size of the outer rect.
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
- const { foundation, tile } = building;
87
- const buildingBase = new Vector2(tile.rx, tile.ry);
88
- const buildingSize = {
89
- width: foundation?.width,
90
- height: foundation?.height,
91
- };
92
- const range = computeAdjacentRect(buildingBase, buildingSize, technoRules.adjacent);
93
- const baseTile = game.mapApi.getTile(range.x, range.y);
94
- if (!baseTile) {
95
- continue;
96
- }
97
- const adjacentTiles = game.mapApi.getTilesInRect(baseTile, {
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
- tiles.push(...adjacentTiles);
102
-
103
- // Prevent placing the new building on tiles that would cause it to overlap with this building.
104
- const modifiedBase = new Vector2(
105
- buildingBase.x - (newBuildingWidth - 1),
106
- buildingBase.y - (newBuildingHeight - 1),
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
- const modifiedSize = {
109
- width: buildingSize.width + (newBuildingWidth - 1),
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
- startPoint: Vector2,
162
+ idealPoint: Vector2,
155
163
  technoRules: TechnoRules,
164
+ onWater: boolean = false,
156
165
  minSpace: number = 1,
157
166
  ): { rx: number; ry: number } | undefined {
158
- // Random location, preferably near start location.
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(startPoint, tiles);
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
- actionsApi.toggleRepairWrench(unitId);
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 NO_TARGET_IDLE_TIMEOUT_TICKS = 60;
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
- return this.setSquad(
38
- new Squad(this.getUniqueName(), new CombatSquad(this.rallyArea, this.attackArea, this.radius), this),
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 = -ATTACK_COOLDOWN_TICKS) {}
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 + ATTACK_COOLDOWN_TICKS) {
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 attackArea = this.generateTarget(gameApi, playerData, matchAwareness);
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(actionsApi, unit, centerOfMass);
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(actionsApi, unit, bestUnit);
120
+ actionBatcher.push(manageAttackMicro(unit, bestUnit));
121
+ this.debugLastTarget = `Unit ${bestUnit.id.toString()}`;
113
122
  } else {
114
- manageMoveMicro(actionsApi, unit, targetPoint);
123
+ actionBatcher.push(manageMoveMicro(unit, targetPoint));
124
+ this.debugLastTarget = `@${targetPoint.x},${targetPoint.y}`;
115
125
  }
116
126
  }
117
127
  }