@supalosa/chronodivide-bot 0.5.4 → 0.6.4

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 (124) hide show
  1. package/.env.template +4 -4
  2. package/.github/workflows/npm-publish.yml +24 -0
  3. package/README.md +108 -103
  4. package/dist/bot/bot.js +105 -105
  5. package/dist/bot/logic/awareness.js +136 -136
  6. package/dist/bot/logic/building/antiAirStaticDefence.js +42 -42
  7. package/dist/bot/logic/building/antiGroundStaticDefence.js +34 -34
  8. package/dist/bot/logic/building/{ArtilleryUnit.js → artilleryUnit.js} +18 -18
  9. package/dist/bot/logic/building/basicAirUnit.js +19 -19
  10. package/dist/bot/logic/building/basicBuilding.js +26 -26
  11. package/dist/bot/logic/building/basicGroundUnit.js +19 -19
  12. package/dist/bot/logic/building/buildingRules.js +175 -175
  13. package/dist/bot/logic/building/common.js +19 -19
  14. package/dist/bot/logic/building/harvester.js +16 -16
  15. package/dist/bot/logic/building/powerPlant.js +20 -20
  16. package/dist/bot/logic/building/queueController.js +183 -183
  17. package/dist/bot/logic/building/resourceCollectionBuilding.js +36 -36
  18. package/dist/bot/logic/common/scout.js +126 -126
  19. package/dist/bot/logic/common/utils.js +95 -95
  20. package/dist/bot/logic/composition/alliedCompositions.js +12 -12
  21. package/dist/bot/logic/composition/common.js +1 -1
  22. package/dist/bot/logic/composition/sovietCompositions.js +12 -12
  23. package/dist/bot/logic/map/map.js +44 -44
  24. package/dist/bot/logic/map/sector.js +137 -137
  25. package/dist/bot/logic/mission/actionBatcher.js +91 -91
  26. package/dist/bot/logic/mission/mission.js +122 -122
  27. package/dist/bot/logic/mission/missionController.js +321 -321
  28. package/dist/bot/logic/mission/missionFactories.js +12 -12
  29. package/dist/bot/logic/mission/missions/attackMission.js +214 -214
  30. package/dist/bot/logic/mission/missions/defenceMission.js +82 -82
  31. package/dist/bot/logic/mission/missions/engineerMission.js +63 -63
  32. package/dist/bot/logic/mission/missions/expansionMission.js +60 -60
  33. package/dist/bot/logic/mission/missions/retreatMission.js +33 -33
  34. package/dist/bot/logic/mission/missions/scoutingMission.js +133 -133
  35. package/dist/bot/logic/mission/missions/squads/combatSquad.js +115 -115
  36. package/dist/bot/logic/mission/missions/squads/common.js +57 -57
  37. package/dist/bot/logic/mission/missions/squads/squad.js +1 -1
  38. package/dist/bot/logic/threat/threat.js +22 -22
  39. package/dist/bot/logic/threat/threatCalculator.js +73 -73
  40. package/dist/exampleBot.js +100 -100
  41. package/package.json +32 -29
  42. package/src/bot/bot.ts +161 -161
  43. package/src/bot/logic/awareness.ts +245 -245
  44. package/src/bot/logic/building/antiAirStaticDefence.ts +64 -64
  45. package/src/bot/logic/building/antiGroundStaticDefence.ts +55 -55
  46. package/src/bot/logic/building/artilleryUnit.ts +39 -39
  47. package/src/bot/logic/building/basicAirUnit.ts +39 -39
  48. package/src/bot/logic/building/basicBuilding.ts +49 -49
  49. package/src/bot/logic/building/basicGroundUnit.ts +39 -39
  50. package/src/bot/logic/building/buildingRules.ts +250 -250
  51. package/src/bot/logic/building/common.ts +21 -21
  52. package/src/bot/logic/building/harvester.ts +31 -31
  53. package/src/bot/logic/building/powerPlant.ts +32 -32
  54. package/src/bot/logic/building/queueController.ts +297 -297
  55. package/src/bot/logic/building/resourceCollectionBuilding.ts +52 -52
  56. package/src/bot/logic/common/scout.ts +183 -183
  57. package/src/bot/logic/common/utils.ts +120 -120
  58. package/src/bot/logic/composition/alliedCompositions.ts +22 -22
  59. package/src/bot/logic/composition/common.ts +3 -3
  60. package/src/bot/logic/composition/sovietCompositions.ts +21 -21
  61. package/src/bot/logic/map/map.ts +66 -66
  62. package/src/bot/logic/map/sector.ts +174 -174
  63. package/src/bot/logic/mission/actionBatcher.ts +124 -124
  64. package/src/bot/logic/mission/mission.ts +232 -232
  65. package/src/bot/logic/mission/missionController.ts +413 -413
  66. package/src/bot/logic/mission/missionFactories.ts +51 -51
  67. package/src/bot/logic/mission/missions/attackMission.ts +336 -336
  68. package/src/bot/logic/mission/missions/defenceMission.ts +151 -151
  69. package/src/bot/logic/mission/missions/engineerMission.ts +113 -113
  70. package/src/bot/logic/mission/missions/expansionMission.ts +104 -104
  71. package/src/bot/logic/mission/missions/retreatMission.ts +54 -54
  72. package/src/bot/logic/mission/missions/scoutingMission.ts +186 -186
  73. package/src/bot/logic/mission/missions/squads/combatSquad.ts +160 -160
  74. package/src/bot/logic/mission/missions/squads/common.ts +63 -63
  75. package/src/bot/logic/mission/missions/squads/squad.ts +19 -19
  76. package/src/bot/logic/threat/threat.ts +15 -15
  77. package/src/bot/logic/threat/threatCalculator.ts +100 -100
  78. package/src/exampleBot.ts +111 -111
  79. package/tsconfig.json +73 -73
  80. package/dist/bot/logic/awarenessImpl.js +0 -132
  81. package/dist/bot/logic/awarenessImpl.js.map +0 -1
  82. package/dist/bot/logic/building/building.js +0 -126
  83. package/dist/bot/logic/building/building.js.map +0 -1
  84. package/dist/bot/logic/mission/behaviours/combatSquad.js +0 -124
  85. package/dist/bot/logic/mission/behaviours/combatSquad.js.map +0 -1
  86. package/dist/bot/logic/mission/behaviours/common.js +0 -58
  87. package/dist/bot/logic/mission/behaviours/common.js.map +0 -1
  88. package/dist/bot/logic/mission/behaviours/engineerSquad.js +0 -39
  89. package/dist/bot/logic/mission/behaviours/engineerSquad.js.map +0 -1
  90. package/dist/bot/logic/mission/behaviours/expansionSquad.js +0 -46
  91. package/dist/bot/logic/mission/behaviours/expansionSquad.js.map +0 -1
  92. package/dist/bot/logic/mission/behaviours/retreatSquad.js +0 -31
  93. package/dist/bot/logic/mission/behaviours/retreatSquad.js.map +0 -1
  94. package/dist/bot/logic/mission/behaviours/scoutingSquad.js +0 -94
  95. package/dist/bot/logic/mission/behaviours/scoutingSquad.js.map +0 -1
  96. package/dist/bot/logic/mission/missions/basicMission.js +0 -13
  97. package/dist/bot/logic/mission/missions/basicMission.js.map +0 -1
  98. package/dist/bot/logic/mission/missions/missionBehaviour.js +0 -2
  99. package/dist/bot/logic/mission/missions/missionBehaviour.js.map +0 -1
  100. package/dist/bot/logic/mission/missions/oneTimeMission.js +0 -27
  101. package/dist/bot/logic/mission/missions/oneTimeMission.js.map +0 -1
  102. package/dist/bot/logic/squad/behaviours/attackSquad.js +0 -89
  103. package/dist/bot/logic/squad/behaviours/combatSquad.js +0 -102
  104. package/dist/bot/logic/squad/behaviours/combatSquad.js.map +0 -1
  105. package/dist/bot/logic/squad/behaviours/common.js +0 -40
  106. package/dist/bot/logic/squad/behaviours/common.js.map +0 -1
  107. package/dist/bot/logic/squad/behaviours/defenceSquad.js +0 -61
  108. package/dist/bot/logic/squad/behaviours/engineerSquad.js +0 -36
  109. package/dist/bot/logic/squad/behaviours/engineerSquad.js.map +0 -1
  110. package/dist/bot/logic/squad/behaviours/expansionSquad.js +0 -43
  111. package/dist/bot/logic/squad/behaviours/expansionSquad.js.map +0 -1
  112. package/dist/bot/logic/squad/behaviours/retreatSquad.js +0 -28
  113. package/dist/bot/logic/squad/behaviours/retreatSquad.js.map +0 -1
  114. package/dist/bot/logic/squad/behaviours/scoutingSquad.js +0 -86
  115. package/dist/bot/logic/squad/behaviours/scoutingSquad.js.map +0 -1
  116. package/dist/bot/logic/squad/squad.js +0 -126
  117. package/dist/bot/logic/squad/squad.js.map +0 -1
  118. package/dist/bot/logic/squad/squadBehaviour.js +0 -6
  119. package/dist/bot/logic/squad/squadBehaviour.js.map +0 -1
  120. package/dist/bot/logic/squad/squadBehaviours.js +0 -7
  121. package/dist/bot/logic/squad/squadBehaviours.js.map +0 -1
  122. package/dist/bot/logic/squad/squadController.js +0 -199
  123. package/dist/bot/logic/squad/squadController.js.map +0 -1
  124. /package/dist/bot/logic/building/{ArtilleryUnit.js.map → artilleryUnit.js.map} +0 -0
@@ -1,413 +1,413 @@
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<any>[] = [];
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<number, Mission<any>> = 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<string, number> = 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<any>[] = 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<string, any> = new Map();
90
- const disbandedMissionsArray: { mission: Mission<any>; 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<number, MissionWithAction<MissionActionRequestSpecificUnits>>,
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<string, Record<string, number>>,
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<string, MissionWithAction<MissionActionRequestUnits>>,
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<any> | 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<string, Record<string, Record<"type" | "grab", number>>>,
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<string, MissionWithAction<MissionActionRequestUnits>>,
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<string, number> {
344
- return this.requestedUnitTypes;
345
- }
346
-
347
- private addUnitToMission(mission: Mission<any>, 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<any>, 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<any>): Mission<any> | 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
- }
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<any>[] = [];
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<number, Mission<any>> = 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<string, number> = 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<any>[] = 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<string, any> = new Map();
90
+ const disbandedMissionsArray: { mission: Mission<any>; 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<number, MissionWithAction<MissionActionRequestSpecificUnits>>,
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<string, Record<string, number>>,
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<string, MissionWithAction<MissionActionRequestUnits>>,
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<any> | 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<string, Record<string, Record<"type" | "grab", number>>>,
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<string, MissionWithAction<MissionActionRequestUnits>>,
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<string, number> {
344
+ return this.requestedUnitTypes;
345
+ }
346
+
347
+ private addUnitToMission(mission: Mission<any>, 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<any>, 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<any>): Mission<any> | 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
+ }