File size: 32,012 Bytes
b6a38d7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
function AIKeywordsCombo()
	return {
		"Control",
		"Explosives",
		"Sniper",
		"Soldier",
		"Ordnance",
		"Smoke",
		"Flank",
		"MobileShot",
		"RunAndGun",
		"Stim",
		"Nova",
		"Heal",

	}
end

function AIEnvStateCombo()
	local items = {}
	
	ForEachPresetInGroup("GameStateDef", "weather", function(item) table.insert(items, item.id) end)
	ForEachPresetInGroup("GameStateDef", "time of day", function(item) table.insert(items, item.id) end)
	return items
end

-- Base class
DefineClass.AISignatureAction = {
	__parents = { "AIBiasObj", },

	properties = {
		{ id = "NotificationText", name = "Notification Text", editor = "text", translate = true, default = ""  },
		{ id = "RequiredKeywords", editor = "string_list", default = {}, item_default = "", items = AIKeywordsCombo, arbitrary_value = true, },
		{ id = "AvailableInState", name = "Available In", editor = "set", default = set(), items = AIEnvStateCombo },
		{ id = "ForbiddenInState", name = "Forbidden In", editor = "set", default = set(), items = AIEnvStateCombo },
	},

	hidden = false,
	movement = false,
	voice_response = false, -- if a non-empty string, play that responce; if empty string play nothing, if false play default response
}

function AISignatureAction:GetEditorView()
	return self.class
end

function AISignatureAction:MatchUnit(unit)
	for state, _ in pairs(self.AvailableInState) do
		if not GameStates[state] then
			return
		end
	end
	for state, _ in pairs(self.ForbiddenInState) do
		if GameStates[state] then
			return
		end
	end
	
	for _, keyword in ipairs(self.RequiredKeywords) do
		if not table.find(unit.AIKeywords or empty_table, keyword) then
			return
		end
	end
	return true
end

function AISignatureAction:PrecalcAction(context, action_state)	
end

function AISignatureAction:IsAvailable(context, action_state)
	return false
end

function AISignatureAction:Execute(context, action_state)	
end

function AISignatureAction:GetVoiceResponse()
	return self.voice_response
end

function AISignatureAction:OnActivate(unit)
	if (self.NotificationText or "") ~= "" then
		ShowTacticalNotification("enemyAttack", false, self.NotificationText)
	end
	return AIBiasObj.OnActivate(self, unit)
end

---------------------------------------
DefineClass.AIActionBasicAttack = {
	__parents = { "AISignatureAction", },
}

function AIActionBasicAttack:PrecalcAction(context, action_state)
	local unit = context.unit
	local dest = context.ai_destination or GetPackedPosAndStance(unit)
	local target = (context.dest_target or empty_table)[dest]

	if not IsValidTarget(target) then
		return
	end
	
	local 	cost = context.default_attack_cost
	if cost >= 0 and unit:HasAP(cost) then
		action_state.args = {target = target}
		action_state.has_ap = true
	end
end

function AIActionBasicAttack:IsAvailable(context, action_state)
	return action_state.has_ap
end

function AIActionBasicAttack:Execute(context, action_state)
	assert(action_state.has_ap)
	
	AIPlayCombatAction(context.default_attack.id, context.unit, nil, action_state.args)
end

-- area attack base classes
DefineClass.AIActionBaseZoneAttack = {
	__parents = { "AISignatureAction", },
	properties = {
		{ id = "enemy_score", name = "Enemy Hit Score", editor = "number", default = 100, },
		{ id = "team_score", name = "Teammate Hit Score", editor = "number", default = -1000, },
		{ id = "self_score_mod", name = "Self Score Modifier", editor = "number", scale = "percent", default = -100, help = "Score will be modified with this value if the targeted zone includes the unit performing the attack" },
		{ id = "min_score", name = "Score Threshold", editor = "number", default = 200, help = "Action will not be taken if best score is lower than this", },
	},
	action_id = false,
	hidden = true,
}

function AIEvalZones(context, zones, min_score, enemy_score, team_score, self_score_mod)
	local best_target, best_score = nil, (min_score or 0) - 1
	
	for _, zone in ipairs(zones) do
		local score
		local selfmod = 0
		for _, unit in ipairs(zone.units) do		
			local uscore = 0
			if not unit:IsDead() and not unit:IsDowned() then
				if unit:IsOnEnemySide(context.unit) then
					uscore = enemy_score or 0
				elseif unit.team == context.unit.team then
					uscore = team_score or 0
					if unit == context.unit then
						selfmod = self_score_mod or 0
					end
				end
			end
			score = (score or 0) + uscore
		end
		score = score and MulDivRound(score, zone.score_mod or 100, 100)
		score = score and MulDivRound(score, 100 + selfmod, 100)		
		if score and score > best_score then
			best_target, best_score = zone, score
		end
		zone.score = score
	end
	
	return best_target, best_score
end

function AIActionBaseZoneAttack:EvalZones(context, zones)
	return AIEvalZones(context, zones, self.min_score, self.enemy_score, self.team_score, self.self_score_mod)
end

DefineClass.AIActionBaseConeAttack = {
	__parents = { "AIActionBaseZoneAttack", },
	properties = {
		{ id = "self_score_mod", editor = "number", default = 0, no_edit = true },
	},
}

MapVar("g_LastSelectedZone", false)

function DbgShowLastSelectedZone()
	if not g_LastSelectedZone then return end
	
	DbgClearVectors()
	local start = g_LastSelectedZone.poly[#g_LastSelectedZone.poly]
	for _, pt in ipairs(g_LastSelectedZone.poly) do
		DbgAddVector(start:SetTerrainZ(guim), (pt - start):SetZ(0), const.clrWhite)
		start = pt
	end
end

function AIActionBaseConeAttack:PrecalcAction(context, action_state)
	if not IsKindOf(context.weapon, "Firearm") then
		return
	end
	
	local caction = CombatActions[self.action_id]
	if not caction or caction:GetUIState({context.unit}) ~= "enabled" then return end
	
	local args, has_ap = AIGetAttackArgs(context, caction, nil, "None")
	action_state.has_ap = has_ap
	if not has_ap then return end
	
	local zones = AIPrecalcConeTargetZones(context, self.action_id, nil, action_state.stance)
	local zone, best_score = self:EvalZones(context, zones)
	action_state.score = best_score
	args.target_pos = zone and zone.target_pos
	args.target = zone and zone.target_pos
	action_state.args = args
	
	g_LastSelectedZone = zone
end

function AIActionBaseConeAttack:IsAvailable(context, action_state)
	return action_state.has_ap and action_state.args.target_pos
end

function AIActionBaseConeAttack:Execute(context, action_state)
	assert(action_state.has_ap)
	AIPlayCombatAction(self.action_id, context.unit, nil, action_state.args)
end

-- actions
---------------------------------------
DefineClass.AIActionThrowGrenade = {
	__parents = { "AIActionBaseZoneAttack", },
	properties = {
		{ id = "MinDist", editor = "number", scale = "m", default = 2*guim, min = 0 },
		{ id = "MaxDist", editor = "number", scale = "m", default = 100*guim, min = 0 },
		{ id = "AllowedAoeTypes", editor = "set", items = {"none", "fire", "smoke", "teargas", "toxicgas"}, default = set("none") },
		{ id = "TargetLastAttackPos", editor = "bool", default = false },
 	},
	hidden = false,
	voice_response = "AIThrowGrenade",

}

function AIActionThrowGrenade:PrecalcAction(context, action_state)
	local action_id, grenade
	local actions = { "ThrowGrenadeA", "ThrowGrenadeB", "ThrowGrenadeC", "ThrowGrenadeD" }
	for _, id in ipairs(actions) do
		local caction = CombatActions[id]
		local cost = caction and caction:GetAPCost(context.unit) or -1
		if cost > 0 and context.unit:HasAP(cost) then
			action_id = id
			local weapon = caction:GetAttackWeapons(context.unit)
			local aoetype = weapon.aoeType or "none"
			if IsKindOf(weapon, "Grenade") and self.AllowedAoeTypes[aoetype] then
				grenade = weapon			
				break
			end
		end
	end
	
	if not action_id or not grenade then
		return
	end
	
	local max_range = Min(self.MaxDist, grenade:GetMaxAimRange(context.unit) * const.SlabSizeX)
	local blast_radius = grenade.AreaOfEffect * const.SlabSizeX
	local target_pts
	if self.TargetLastAttackPos then 
		-- collect enemy last attack positions and pass them as target_pos array to AIPrecalcGrenadeZones
		for _, enemy in ipairs(context.enemies) do
			if enemy.last_attack_pos then
				target_pts = target_pts or {}
				target_pts[#target_pts + 1] = enemy.last_attack_pos
			end
		end
	end
	local zones = AIPrecalcGrenadeZones(context, action_id, self.MinDist, max_range, blast_radius, grenade.aoeType, target_pts)
	local zone, score = self:EvalZones(context, zones)
	if zone then
		action_state.action_id = action_id
		action_state.target_pos = zone.target_pos
		action_state.score = score
	end
end

function AIActionThrowGrenade:IsAvailable(context, action_state)
	return not not action_state.action_id
end

function AIActionThrowGrenade:Execute(context, action_state)
	assert(action_state.action_id and action_state.target_pos)
	AIPlayCombatAction(action_state.action_id, context.unit, nil, {target = action_state.target_pos})
end
---------------------------------------
DefineClass.AIConeAttack = {
	__parents = { "AIActionBaseConeAttack", },
	properties = {
		{ id = "action_id", editor = "dropdownlist", items = {"Buckshot", "DoubleBarrel", "Overwatch"}, default = "Buckshot" },
	},
	hidden = false,
}

function AIConeAttack:GetEditorView()
	return string.format("Cone Attack (%s)", self.action_id)
end

function AIConeAttack:Execute(context, action_state)
	AIActionBaseConeAttack.Execute(self, context, action_state)
	if self.action_id == "Overwatch" then
		return "done"
	end
end

function AIConeAttack:GetVoiceResponse()
	if self.action_id == "Overwatch" then
		return "AIOverwatch"
	end
	return self.voice_response
end
---------------------------------------
DefineClass.AIActionBandage = {
	__parents = { "AISignatureAction", "AIBaseHealPolicy", },
	voice_response = "",
}

function AIActionBandage:IsAvailable(context, action_state)
	return action_state.has_ap
end

function AIActionBandage:Execute(context, action_state)
	assert(action_state.has_ap)
	if action_state.args.target then
		if not IsMeleeRangeTarget(context.unit, nil, nil, action_state.args.target) then
			return
		end
		context.unit:Face(action_state.args.target)
	end
	AIPlayCombatAction("Bandage", context.unit, nil, action_state.args)
	return "stop"
end

function AIActionBandage:PrecalcAction(context, action_state)
	local unit = context.unit
	local x, y, z = unit:GetGridCoords()
	local grid_voxel = point_pack(x, y, z)
	local dest = GetPackedPosAndStance(unit)
	local target = AISelectHealTarget(context, dest, grid_voxel, self)
	
	if target then
		action_state.args = { 
			target = target,
			goto_pos = SnapToVoxel(unit:GetPos()),
		}
		local cost = CombatActions.Bandage:GetAPCost(unit, action_state.args)
		action_state.has_ap = (cost >= 0) and unit:HasAP(cost)
	end
end
---------------------------------------
DefineClass.AIStimRule = {
	__parents = { "PropertyObject" },
	properties = {
		{ id = "Keyword", editor = "dropdownlist", default = "", items = AIKeywordsCombo },
		{ id = "Weight", editor = "number", default = 0 },
	},
}
DefineClass.AIActionStim = {
	__parents = { "AISignatureAction", },
	properties = {
		{ id = "TargetRules", editor = "nested_list", default = false, base_class = "AIStimRule", inclusive = true },
		{ id = "CanTargetSelf", editor = "bool", default = false },
	},
	voice_response = "",
}

function AIActionStim:IsAvailable(context, action_state)
	return action_state.has_ap and IsValid(action_state.target)
end

function AIActionStim:Execute(context, action_state)
	assert(action_state.has_ap and IsValid(action_state.target))
	
	-- just fake it
	context.unit:ConsumeAP(CombatStim.APCost * const.Scale.AP)
	for _, effect in ipairs(CombatStim.Effects) do
		effect:__exec(action_state.target)
	end
end

function AIActionStim:PrecalcAction(context, action_state)
	local cost = CombatStim.APCost * const.Scale.AP
	local unit = context.unit
	action_state.has_ap = unit:HasAP(cost)	
	if not action_state.has_ap then return end
	
	local best_score, best_target = 0, false
	if self.CanTargetSelf then
		best_score = AIEvalStimTarget(unit, unit, self.TargetRules)
		best_target = (best_score > 0) and unit
	end
	
	for _, ally in ipairs(context.allies) do
		if IsMeleeRangeTarget(unit, nil, nil, ally) then
			local score = AIEvalStimTarget(unit, ally, self.TargetRules)
			if score > best_score then
				best_score, best_target = score, ally
			elseif score == best_score and IsValid(best_target) then
				if unit:GetDist(ally) < unit:GetDist(best_target) then
					best_target = ally
				end
			end
		end
	end
	action_state.target = best_target
end
---------------------------------------
DefineClass.AIActionCharge = {
	__parents = { "AISignatureAction", },
	properties = {
		{ id = "DestPreference", editor = "dropdownlist", items = {"score", "nearest"}, default = "score", help = "Specifies the way a charge destination and target are selected when the destination picked by the general AI logic isn't a valid Charge destination.\n'score' picks the destination with highest evaluation, while 'nearest' opts for the destination nearest to the general destination already picked" },
	},
	movement = true,
	action_id = "Charge",
}

function AIActionCharge:IsAvailable(context, action_state)
	return not not action_state.args
end

function AIActionCharge:GetActionId(unit)
	return HasPerk(unit, "GloryHog") and "GloryHog" or "Charge"
end

function AIActionCharge:Execute(context, action_state)
	assert(action_state.has_ap)
	if #CombatActions_Waiting > 0 or (next(CombatActions_RunningState) ~= nil) then
		-- do not queue together with other move actions to avoid shooting actions going back and forth between units
		return "restart"
	end
	local action_id = self:GetActionId(context.unit)
	AIPlayCombatAction(action_id, context.unit, nil, action_state.args)	
end

function AIActionCharge:PrecalcAction(context, action_state)
	local unit = context.unit
	local action_id = self:GetActionId(unit)
	local action = CombatActions[action_id]
		
	-- check action state
	local units = {unit}
	local state = action:GetUIState(units)
	local cost = action:GetAPCost(unit)
	if state ~= "enabled" or (cost > 0 and not unit:HasAP(cost)) then return end
	
	-- check if we have valid targets and the resulting positions
	local targets = action:GetTargets(units)
	local move_ap = action:ResolveValue("move_ap") * const.Scale.AP
	local args, score, dist
	local pref = context.ai_destination and self.DestPreference or "score"
	
	for _, target in ipairs(targets) do
		local atk_pos = GetChargeAttackPosition(unit, target, move_ap, action_id)
		local atk_dest = stance_pos_pack(atk_pos, StancesList.Standing)
		local atk_dist = context.ai_destination and stance_pos_dist(context.ai_destination, atk_dest)
		-- prefer selected dest if possible, select the dest with highest overall score otherwise?
		if atk_dist and atk_dist == 0 then
			args = { target = target, goto_pos = atk_pos }
			break
		end
		if pref == "score" then
			local dest_score = context.dest_scores[atk_dest] or 0
			if not args or (dest_score > score) then
				args = {target = target, goto_pos = atk_pos}
				score = dest_score
			end
		elseif pref == "nearest" then
			if not args or atk_dist < dist then
				args = {target = target, goto_pos = atk_pos}
				dist = atk_dist
			end
		else
			assert(false, string.format("unknown dest preference for AI Charge (%s), aborting", tostring(pref)))
			break
		end
	end
	
	if not args then return end
	
	args.goto_ap = CombatActions.Move:GetAPCost(unit, {goto_pos = args.goto_pos})
	action_state.args = args
end
---------------------------------------
DefineClass.AIActionHyenaCharge = {
	__parents = { "AISignatureAction", },
	properties = {
		{ id = "DestPreference", editor = "dropdownlist", items = {"score", "nearest"}, default = "score", help = "Specifies the way a charge destination and target are selected when the destination picked by the general AI logic isn't a valid Charge destination.\n'score' picks the destination with highest evaluation, while 'nearest' opts for the destination nearest to the general destination already picked" }
	},
	movement = true,
	action_id = "HyenaCharge",
}

function AIActionHyenaCharge:IsAvailable(context, action_state)
	return not not action_state.args
end

function AIActionHyenaCharge:Execute(context, action_state)
	assert(action_state.args)
	if #CombatActions_Waiting > 0 or (next(CombatActions_RunningState) ~= nil) then
		-- do not queue together with other move actions to avoid shooting actions going back and forth between units
		return "restart"
	end
	AIPlayCombatAction(self.action_id, context.unit, nil, action_state.args)	
end

function AIActionHyenaCharge:PrecalcAction(context, action_state)
	local unit = context.unit
	local action = CombatActions[self.action_id]
		
	-- check action state
	local units = {unit}
	local state = action:GetUIState(units)
	local cost = action:GetAPCost(unit)
	if state ~= "enabled" or (cost > 0 and not unit:HasAP(cost)) then return end
	
	-- check if we have valid targets and the resulting positions
	local targets = action:GetTargets(units)
	local move_ap = action:ResolveValue("move_ap") * const.Scale.AP
	local args, score, dist
	local pref = context.ai_destination and self.DestPreference or "score"
	
	for _, target in ipairs(targets) do
		local atk_pos = GetHyenaChargeAttackPosition(unit, target, move_ap, false, self.action_id)
		local atk_dest = stance_pos_pack(atk_pos, 0)
		local atk_dist = context.ai_destination and stance_pos_dist(context.ai_destination, atk_dest)
		-- prefer selected dest if possible, select the dest with highest overall score otherwise?
		if atk_dist and atk_dist == 0 then
			args = { target = target }
			break
		end
		if pref == "score" then
			local dest_score = context.dest_scores[atk_dest] or 0
			if not args or (dest_score > score) then
				args = {target = target }
				score = dest_score
			end
		elseif pref == "nearest" then
			if not args or atk_dist < dist then
				args = {target = target }
				dist = atk_dist
			end
		else
			assert(false, string.format("unknown dest preference for AI Charge (%s), aborting", tostring(pref)))
			break
		end
	end
	
	action_state.args = args
end
---------------------------------------
DefineClass.AIActionMobileShot = {
	__parents = { "AISignatureAction", },
	properties = {
		{ id = "action_id", name = "Action", editor = "dropdownlist", items = {"MobileShot", "RunAndGun"}, default = "MobileShot" },
	},
	movement = true,
	default_notification_texts = {
		MobileShot = T(222119395990, "Mobile Shot"),
		RunAndGun = T(439839298337, "Run and Gun"),
	},
	voice_response = "AIMobile", -- both attacks use the same VR
}

function AIActionMobileShot:GetDefaultPropertyValue(prop, prop_meta)
	if prop == "NotificationText" then		
		return self.default_notification_texts[self.action_id] or prop_meta.default
	end
	return AISignatureAction.GetDefaultPropertyValue(self, prop, prop_meta)
end

function AIActionMobileShot:SetProperty(property, value)
	if property == "action_id" then		
		local meta = self:GetPropertyMetadata("NotificationText")
		local cur_default_text = self.default_notification_texts[self.action_id] or meta.default
		local new_default_text = self.default_notification_texts[value] or meta.default
		if self.NotificationText == cur_default_text then
			self:SetProperty("NotificationText", new_default_text)
		end
	end
	return AISignatureAction.SetProperty(self, property, value)
end

function AIActionMobileShot:GetEditorView()
	return string.format("Mobile Attack (%s)", self.action_id)
end

function AIActionMobileShot:IsAvailable(context, action_state)
	return action_state.has_ap
end

function AIActionMobileShot:Execute(context, action_state)
	assert(action_state.has_ap)
	if #CombatActions_Waiting > 0 or (next(CombatActions_RunningState) ~= nil) then
		-- do not queue together with other move actions to avoid shooting actions going back and forth between units
		return "restart"
	end
	AIPlayCombatAction(self.action_id, context.unit, nil, action_state.args)	
end

function AIActionMobileShot:PrecalcAction(context, action_state)
	local unit = context.unit
	local action = CombatActions[self.action_id]
	
	-- only available to reach the already chosen dest
	if not context.ai_destination then return end
	
	-- check action state
	local state = action:GetUIState({unit})
	if state ~= "enabled" then return end
	
	-- check if the action would do something
	local x, y, z = stance_pos_unpack(context.ai_destination)
	local target_pos = point(x, y, z)
	local shot_voxels, shot_targets, shot_ch, canceling_reason = CalcMobileShotAttacks(unit, action, target_pos)
	shot_voxels = shot_voxels or empty_table
	shot_targets = shot_targets or empty_table

	if shot_voxels[1] and not canceling_reason[1] and IsValidTarget(shot_targets[1]) then
		action_state.args = { 
			goto_pos = target_pos,
		}
		local cost = action:GetAPCost(unit, action_state.args)
		action_state.has_ap = (cost >= 0) and unit:HasAP(cost)
	end
end
---------------------------------------
DefineClass.AIActionPinDown = {
	__parents = { "AISignatureAction", },
	voice_response = "AIPinDown",
}

function AIActionPinDown:PrecalcAction(context, action_state)
	if IsKindOf(context.weapon, "Firearm") then
		local args, has_ap = AIGetAttackArgs(context, CombatActions.PinDown, nil, "None")
		action_state.args = args
		action_state.has_ap = has_ap
	end
end

function AIActionPinDown:IsAvailable(context, action_state)
	if not action_state.has_ap then
		return false
	end
	
	local target = action_state.args.target
	
	-- filter targets that are already pinned down
	for attacker, descr in pairs(g_Pindown) do
		if descr.target == target then
			return false
		end
	end
	
	return IsValidTarget(target) and context.unit:HasPindownLine(target, action_state.args.target_spot_group or "Torso")
end

function AIActionPinDown:Execute(context, action_state)
	assert(action_state.has_ap)
	local target = action_state.args.target
	AIPlayCombatAction("PinDown", context.unit, nil, action_state.args)
	return "done"
end
---------------------------------------
DefineClass.AIActionShootLandmine = {
	__parents = { "AIActionBaseZoneAttack", },
	hidden = false,
}

function AIActionShootLandmine:PrecalcAction(context, action_state)
	local zones = AIPrecalcLandmineZones(context)
	local zone, score = self:EvalZones(context, zones)
	if zone then
		local args, has_ap = AIGetAttackArgs(context, context.default_attack, nil, "None", zone.target)
		if has_ap then
			action_state.score = score
			action_state.args = args
			action_state.has_ap = has_ap
		end
	end
end

function AIActionShootLandmine:IsAvailable(context, action_state)
	return action_state.has_ap
end

function AIActionShootLandmine:Execute(context, action_state)
	assert(action_state.has_ap)
	AIPlayCombatAction(context.default_attack.id, context.unit, nil, action_state.args)
end
---------------------------------------
DefineClass.AIActionSingleTargetShot = {
	__parents = { "AISignatureAction", },
	properties = {
		{ id = "action_id", editor = "dropdownlist", items = {"SingleShot", "BurstFire", "AutoFire", "Buckshot", "DoubleBarrel", "KnifeThrow"}, default = "SingleShot" },
		{ id = "Aiming", 
			editor = "choice", default = "None", items = function (self) return { "None", "Remaining AP", "Maximum"} end, },
		{ id = "AttackTargeting", help = "if any parts are set the unit will pick one of them randomly for each of its basic attacks; otherwise it will always use the default (torso) attacks", 
			editor = "set", default = false, items = function (self) return table.keys2(Presets.TargetBodyPart.Default) end, },
	},
	
	default_notification_texts = {
		AutoFire = T(730263043731, "Full Auto"),
		DoubleBarrel = T(937676786920, "Double Barrel Shot"),
	},
}

function AIActionSingleTargetShot:GetDefaultPropertyValue(prop, prop_meta)
	if prop == "NotificationText" then		
		return self.default_notification_texts[self.action_id] or prop_meta.default
	end
	return AISignatureAction.GetDefaultPropertyValue(self, prop, prop_meta)
end

function AIActionSingleTargetShot:SetProperty(property, value)
	if property == "action_id" then		
		local meta = self:GetPropertyMetadata("NotificationText")
		local cur_default_text = self.default_notification_texts[self.action_id] or meta.default
		local new_default_text = self.default_notification_texts[value] or meta.default
		if self.NotificationText == cur_default_text then
			self:SetProperty("NotificationText", new_default_text)
		end
	end
	return AISignatureAction.SetProperty(self, property, value)
end

function AIActionSingleTargetShot:GetEditorView()
	return string.format("Single Target Attack (%s)", self.action_id)
end

function AIActionSingleTargetShot:PrecalcAction(context, action_state)
	if IsKindOf(context.weapon, "Firearm") and not IsKindOf(context.weapon, "HeavyWeapon") then
		local action = CombatActions[self.action_id]
		
		local unit = context.unit
		local upos = GetPackedPosAndStance(unit)
		local target = context.dest_target[upos]
		
		local body_parts = AIGetAttackTargetingOptions(unit, context, target, action, self.AttackTargeting)
		local targeting
		if body_parts and #body_parts > 0 then
			local pick = table.weighted_rand(body_parts, "chance", InteractionRand(1000000, "Combat"))
			targeting = pick and pick.id or nil
		end

		assert(action)
		local args, has_ap = AIGetAttackArgs(context, action, targeting or "Torso", self.Aiming)
		action_state.args = args
		action_state.has_ap = has_ap
		if has_ap and IsValidTarget(args.target) then
			local results = action:GetActionResults(context.unit, args)
			action_state.has_ammo = not not results.fired
			action_state.can_hit = results.chance_to_hit > 0
		end
	end
end

function AIActionSingleTargetShot:IsAvailable(context, action_state)
	if not action_state.has_ap or not action_state.has_ammo or not action_state.can_hit then
		return false
	end
	
	return IsValidTarget(action_state.args.target)
end

function AIActionSingleTargetShot:Execute(context, action_state)
	assert(action_state.has_ap)
	
	AIPlayCombatAction(self.action_id, context.unit, nil, action_state.args)
end

function AIActionSingleTargetShot:GetVoiceResponse()
	local action_id = self.action_id
	if action_id and (action_id == "DoubleBarrel" or action_id == "Buckshot" or  action_id == "BuckshotBurst") then
		return "AIDoubleBarrel"
	end
	return self.voice_response
end
---------------------------------------
DefineClass.AIAttackSingleTarget = {
	__parents = { "AIActionSingleTargetShot", },
}
---------------------------------------
DefineClass.AIActionCancelShot = {
	__parents = { "AIActionSingleTargetShot", },
	properties = { 
		{ id = "action_id", editor = "dropdownlist", items = {"CancelShot"}, default = "CancelShot", no_edit = true },
	},
}

function AIActionCancelShot:IsAvailable(context, action_state)
	if not action_state.has_ap then
		return false
	end
	
	local target = action_state.args.target
	return IsValidTarget(target) and (target:HasPreparedAttack() or target:CanActivatePerk("MeleeTraining"))
end
---------------------------------------
DefineClass.AIActionMGSetup = {
	__parents = { "AIActionBaseConeAttack", },
	properties = {
		{ id = "cur_zone_mod", name = "Current Zone Modifier", editor = "number", scale = "%", default = 100, help = "Modifier applied when scoring the already set zone" },
	},
	action_id = "MGSetup",
	hidden = false,
}

function AIActionMGSetup:PrecalcAction(context, action_state)
	if not context.unit:HasStatusEffect("StationedMachineGun") then
		-- setup
		action_state.stance = "Prone" -- MGSetup will change the stance so we need to check LOS in that stance
		AIActionBaseConeAttack.PrecalcAction(self, context, action_state)
	else
		local curr_target_pt = g_Overwatch[context.unit] and g_Overwatch[context.unit].target_pos
		local zones = AIPrecalcConeTargetZones(context, self.action_id, curr_target_pt)
		local cur_zone = zones[#zones]
		if not cur_zone then
			return
		end
		cur_zone.score_mod = self.cur_zone_mod
		local zone, best_score = self:EvalZones(context, zones)
	
		-- check best zone:
		if not zone then -- no suitable zone, pack up
			action_state.action_id = "MGPack"
		elseif zone ~= cur_zone then -- another best zone, rotate
			action_state.action_id = "MGRotate"
			action_state.target_pos = zone.target_pos
		end

		if action_state.action_id then
			action_state.score = best_score
			action_state.target_pos = zone and zone.target_pos
			
			local caction = CombatActions[action_state.action_id]
			if not caction then return end
			
			local args, has_ap = AIGetAttackArgs(context, caction, nil, "None")
			action_state.has_ap = has_ap
			if has_ap then 
				g_LastSelectedZone = zone
			end
		end
	end
end

function AIActionMGSetup:IsAvailable(context, action_state)
	return action_state.has_ap and (action_state.args and action_state.args.target_pos or action_state.action_id == "MGPack")
end

function AIActionMGSetup:Execute(context, action_state)
	assert(action_state.has_ap)
	local args = {}
	if action_state.action_id ~= "MGPack" then
		assert(action_state.args)
		args.target = action_state.args.target_pos
	end
	AIPlayCombatAction(action_state.action_id or self.action_id, context.unit, nil, args)
	if action_state.action_id == "MGPack" then
		return "restart"
	end
end
---------------------------------------
DefineClass.AIActionMGBurstFire = {
	__parents = { "AIActionSingleTargetShot", },
	properties = { 
		{ id = "action_id", editor = "dropdownlist", items = { "MGBurstFire" }, default = "MGBurstFire", no_edit = true },
	},
	--action_id = "MGBurstFire",
}
function AIActionMGBurstFire:PrecalcAction(context, action_state)
	if context.unit:HasStatusEffect("StationedMachineGun") then
		return AIActionSingleTargetShot.PrecalcAction(self, context, action_state)
	end
end
---------------------------------------
DefineClass.AIActionHeavyWeaponAttack = {
	__parents = { "AIActionBaseZoneAttack", },
	properties = { 
		{ id = "MinDist", editor = "number", scale = "m", default = 2*guim, min = 0 },
		{ id = "MaxDist", editor = "number", scale = "m", default = 100*guim, min = 0 },
		{ id = "SmokeGrenade", editor = "bool", default = false, },
		{ id = "action_id", editor = "dropdownlist", items = { "GrenadeLauncherFire", "RocketLauncherFire", "Bombard" }, default = "GrenadeLauncherFire" },
		{ id = "LimitRange", editor = "bool", default = false },
		{ id = "MaxTargetRange", editor = "number", min = 1, max = 100, default = 20, slider = true, no_edit = function(self) return not self.LimitRange end, },
	},
	hidden = false,
	--voice_response = "AIThrowGrenade",
}

function AIActionHeavyWeaponAttack:GetEditorView()
	return string.format("Heavy Attack (%s)", self.action_id)
end

function AIActionHeavyWeaponAttack:PrecalcAction(context, action_state)
	local caction = CombatActions[self.action_id]
	local cost = caction and caction:GetAPCost(context.unit) or -1
	local weapon = caction and caction:GetAttackWeapons(context.unit)
	
	if not weapon or cost < 0 or not context.unit:HasAP(cost) or not weapon.ammo or weapon.ammo.Amount < 1 then
		return
	end
	if self.SmokeGrenade ~= (weapon.ammo.aoeType == "smoke") then
		return
	end
	if self.action_id == "Bombard" and context.unit.indoors then
		return
	end
	
	local max_range = Min(self.MaxDist, caction:GetMaxAimRange(context.unit, weapon) * const.SlabSizeX)
	local blast_radius = weapon.ammo.AreaOfEffect * const.SlabSizeX
	local zones = AIPrecalcGrenadeZones(context, self.action_id, self.MinDist, max_range, blast_radius, weapon.ammo.aoeType)
	
	if self.LimitRange then
		local attacker = context.unit
		local range = self.MaxTargetRange * const.SlabSizeX
		zones = table.ifilter(zones, function(idx, zone) return attacker:GetDist(zone.target_pos) <= range end)
	end
	
	local zone, score = self:EvalZones(context, zones)
	if zone then
		action_state.action_id = self.action_id
		action_state.target_pos = zone.target_pos
		action_state.score = score
	end
end

function AIActionHeavyWeaponAttack:IsAvailable(context, action_state)
	return not not action_state.action_id
end

function AIActionHeavyWeaponAttack:Execute(context, action_state)
	assert(action_state.action_id and action_state.target_pos)
	AIPlayCombatAction(action_state.action_id, context.unit, nil, {target = action_state.target_pos})
end
---------------------------------------