File size: 53,681 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
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
MapVar("g_UnitAlertThread", false)
MapVar("g_RepositionMarkersClaimed", {})
MapVar("g_NoiseSources", {})
MapVar("g_SuspicionThreads", {})
MapVar("g_UnitAwarenessPending", false)

GameVar("g_AwarenessLog", {})

--[[ 
	Awareness:
		Events that can raise unit Awareness or Suspicion use PushUnitAlert which determines the units who should 
			change their state	and marks them. Different types of events/triggers can behave differently, but whenever
			a unit becomes Aware they will propagate this awareness to nearby allies. Unit awareness state does NOT change.
			
		Actual state change is triggered by calling AlertPendingUnits. The function is safe to be called at any time and is
			called automatically on a number of occasons. It does not, however, guarantee the change would happen immediately.
			The change itself is handled differently, depending if the unit becomes Aware or Suspicious - Suspicious is applied
			directly as it does not involve any actions, whereas Aware involves Reposition phase (and possibly a StartCombat setpiece)
			and is handled by a dedicated AI execution phase ran in an own thread.
						
		TriggerUnitAlert is a convenience function which will call PushUnitAlert and AlertPendingUnits immediately when
			the caller only needs a single alert raised.
--]]

DefineClass.AwareReasons = {
	__parents = { "ListPreset", },
	properties = {
		{ id = "display_name", name = "Display Name", 
			editor = "text", default = false, translate = true, },
	},
}

DefineClass.NoiseTypes = {
	__parents = { "Preset", },
	properties = {
		{ id = "display_name", name = "Display Name", 
			editor = "text", default = false, translate = true, },
	},
}

if Platform.developer or Platform.debug then
	local AwarenessLogMaxLines = 100
	function dbg_awareness_log(...)
		local msg = string.format("[%d] ", GameTime())
		for i = 1, select("#", ...) do
			local obj = select(i, ...)
			local item_str
			if IsValid(obj) then
				if IsKindOf(obj, "Unit") then
					item_str = string.format("%s (%d)", obj.unitdatadef_id, obj.handle)
				else
					item_str = obj.class
				end
			else
				item_str = tostring(obj)
			end
			msg = msg .. item_str
		end
		g_AwarenessLog = g_AwarenessLog or {}
		while #g_AwarenessLog >= AwarenessLogMaxLines do
			table.remove(g_AwarenessLog, 1)
		end
		table.insert(g_AwarenessLog, msg)
	end
else
	dbg_awareness_log = empty_func
end

function PushUnitAlert(trigger_type, ...)
	if trigger_type == "discovered" and CheatEnabled("DisableDiscoveryAlert") then
		return
	end
	local param1, param2 = ...
	NetUpdateHash("PushUnitAlert", trigger_type, param1 and param1.class or "", param2 or 0)

	local pov_team = GetPoVTeam()
	local enemies = pov_team and pov_team.units and GetAllEnemyUnits(pov_team.units[1] or false)
	local enemies_alive
	for _, unit in ipairs(enemies) do
		if IsValidTarget(unit) then
			enemies_alive = true
			break
		end
	end
	if not enemies_alive then
		return 0, 0
	end

	local alerted
	local suspicious = 0
	local surprised = 0

	if trigger_type == "attack" then -- make target and damaged units aware
		local attacker, alerted_obj, from_stealth, hit_objs = ...
		local aware_state = (from_stealth or HasPerk(attacker, "FoxPerk")) and "surprised" or "aware"
		dbg_awareness_log(attacker, " alerts: attack")
		local units = IsValid(alerted_obj) and {alerted_obj} or alerted_obj
		for _, unit in ipairs(units) do
			local state = (g_Combat and unit:HasStatusEffect("Surprised")) and "aware" or aware_state
			local is_aware = unit:IsAware() or unit.pending_aware_state == "aware"
			local reason_id
			if state == "surprised" and not is_aware or (hit_objs and not table.find(hit_objs, unit)) then
				reason_id = "arSurprised"
			else
				reason_id = "arAttack"
			end
			local reason = T{Presets.AwareReasons.Default[reason_id].display_name, enemy = unit.Name, merc = attacker.Nick or attacker.Name}
			if unit:SetPendingAwareState(state, reason, attacker) then
				if state ~= "aware" then
					surprised = surprised + 1
				else
					if not alerted then alerted = {} end
					alerted[#alerted + 1] = unit
				end
				dbg_awareness_log("  ", unit, " alerted")
			end
			if unit.pending_aware_state == "aware" then
				local action = g_CurrentAttackActions[1]
				if action and action.attack_args and action.attack_args.target == unit then
					unit.pending_awareness_role = state == "surprised" and "surprised" or "attacked"
				end
			end
		end
	elseif trigger_type == "death" then
		local actor = ...
		dbg_awareness_log(actor, " alerts: dead")
		-- check units based on their sight/los toward actor
		local units
		for _, team in ipairs(g_Teams) do
			if not team.neutral then
				for _, u in ipairs(team.units) do
					if not u.dummy and not u:IsIncapacitated() and not u:IsAware() then
						local sight = u:GetSightRadius(actor)
						if IsCloser(u, actor, sight + 1) then
							if not units then units = {} end
							units[#units + 1] = u
						end
					end
				end
			end
		end
		if units then
			local los_any, los_targets = CheckLOS(units, actor)
			if los_any then
				for i, los_value in ipairs(los_targets) do
					if los_value then
						local unit = units[i]
						local reason = T{Presets.AwareReasons.Default.arSawDying.display_name, enemy = unit.Name}
						if unit:SetPendingAwareState("surprised", reason) then
							dbg_awareness_log("  ", unit, " is surprised")
							surprised = surprised + 1
						end
					end
				end
			end
		end
	elseif trigger_type == "dead body" then
		local actor, _units = ...
		local units
		for _, unit in ipairs(_units) do
			if not unit.seen_bodies[actor]
				and not (unit.dummy or unit.team.neutral or unit:IsDead() or unit:IsAware()) -- SetPendingAwareState ignores these units
				and IsCloser(unit, actor, unit:GetSightRadius(actor) + 1)
			then
				if not units then units = {} end
				units[#units + 1] = unit
			end
		end
		if units then
			local los_any, los_targets = CheckLOS(units, actor)
			if los_any then
				local aware_state = g_Combat and "surprised" or "suspicious"
				for i, los_value in ipairs(los_targets) do
					if los_value then
						local unit = units[i]
						if unit:SetPendingAwareState(aware_state) then
							unit.suspicious_body_seen = actor:GetHandle()
							unit.seen_bodies[actor] = true
							dbg_awareness_log("  ", unit, " is suspicious")
							suspicious = suspicious + 1
						end
					end
				end
			end
		end
	elseif trigger_type == "noise" then -- aware/suspicious based on range, current awareness
		local actor, radius, soundName, attacker = ... -- actor can be unit or another object (grenade, mine, etc.)
		-- log noise sources (will reset on new turn)
		if GameState.RainLight or GameState.RainHeavy then
			radius = MulDivRound(radius, Max(0, 100 + const.EnvEffects.RainNoiseMod), 100)
		end
		dbg_awareness_log(actor, " alerts: noise ", radius)
		g_NoiseSources[#g_NoiseSources + 1] = {
			actor = actor,
			pos = actor and actor:GetPos(),
			noise = radius,
		}
		radius = radius * const.SlabSizeX
		local alerter = IsKindOf(actor, "Unit") and actor or nil
		if IsKindOf(attacker, "Unit") then
			alerter = attacker
		end
		local state = alerter and HasPerk(alerter, "FoxPerk") and "surprised" or "aware"
		for _, team in ipairs(g_Teams) do
			local side = team.side
			if side ~= "neutral" and (g_Combat or side == "enemy1" or side == "enemy2") then
				for _, unit in ipairs(team.units) do
					if unit ~= actor
						and IsCloser(unit, actor, radius + 1)
						and (not unit:HasStatusEffect("Distracted") or IsCloser(unit, actor, MulDivRound(radius, 66, 100) + 1))
					then
						local reason = T{Presets.AwareReasons.Default.arNoise.display_name, enemy = unit.Name, noise = soundName}
						if unit:SetPendingAwareState(state, reason, alerter) then
							if actor then
								unit.last_known_enemy_pos = actor:GetPos()
							end
							if state == "aware" then
								if not alerted then alerted = {} end
								alerted[#alerted + 1] = unit
							else
								surprised = surprised + 1
							end
							dbg_awareness_log("  ", unit, " alerted")
						end
					end
				end
			end
		end
	elseif trigger_type == "projector" then
		local actor, units, projector = ...
		for i, unit in ipairs(units) do
			if IsCloser(unit, projector, ProjectorSuspiciousApplyRange) then
				local reason = T{Presets.AwareReasons.Default.arProjector.display_name, enemy = unit.Name}
				if unit:SetPendingAwareState("aware", reason, actor) then
					surprised = surprised + 1
				end
			end
		end
	elseif trigger_type == "sight" then -- sight: make unaware units suspicious
		local actor, seen = ... -- actor is unaware unit who saw an enemy. seen is enemy unit seen by actor
		local aware = actor:IsAware() or actor.pending_aware_state == "aware"
		local surprised = actor:HasStatusEffect("Surprised") or actor.pending_aware_state == "surprised"
		if actor:IsOnEnemySide(seen) and not aware and not surprised and actor:SetPendingAwareState("surprised") then
			suspicious = suspicious + 1
			dbg_awareness_log(actor, " is alerted (sight)")
		end
	elseif trigger_type == "thrown" then
		local obj, attacker = ...  --  obj is thrown object
		local units
		for _, team in ipairs(g_Teams) do
			if not team.neutral and (not attacker or attacker.team and team:IsEnemySide(attacker.team)) then
				for _, unit in ipairs(team.units) do
					if not unit:IsDead() and not unit:IsAware("pending") then
						local sight = unit:GetSightRadius(obj)
						if IsCloser(unit, obj, sight + 1) then
							if not units then units = {} end
							units[#units + 1] = unit
						end
					end
				end
			end
		end
		if units then
			local los_any, los_targets = CheckLOS(units, obj)
			if los_any then
				for i, los_value in ipairs(los_targets) do
					if los_value then
						local unit = units[i]
						local reason = T{Presets.AwareReasons.Default.arThrownObject.display_name, enemy = unit.Name}
						if unit:SetPendingAwareState("surprised", reason) then
							dbg_awareness_log("  ", unit, " is surprised")
							surprised = surprised + 1
						end
					end
				end
			end
		end
	elseif trigger_type == "script" then --should this be included in the aware_reason
		local _units, state = ...  -- units to become suspicious/aware
		local units = table.ifilter(_units, function(idx, unit)
			return unit.team and not unit.team.neutral
		end)
		for _, unit in ipairs(units) do	
			unit.pending_aware_state = state
			dbg_awareness_log(unit, " is alerted (script): ", state)
		end
		if state == "aware" then
			alerted = units
		end
	elseif trigger_type == "surprise" then
		local unit, from_suspicious = ...
		local reason
		if from_suspicious then
			reason = T{Presets.AwareReasons.Default.arDeadBody.display_name, enemy = unit.Name}
		end
		if unit:SetPendingAwareState("aware", reason) then
			dbg_awareness_log(unit, " is alerted (surprise)")
			if not alerted then alerted = {} end
			alerted[#alerted + 1] = unit
		end
	elseif trigger_type == "discovered" then -- Alert all enemies who have sight of unit.
		local unit = ...
		local enemyUnits = GetAllEnemyUnits(unit)
		local alertedPeople = 0
		for i, enemyUnit in ipairs(enemyUnits) do
			if not enemyUnit:IsAware() and HasVisibilityTo(enemyUnit, unit) then
				alertedPeople = alertedPeople + 1
				CombatStarDetectedtVR(unit)
				if enemyUnit.pending_aware_state ~= "aware" then
					if not enemyUnit:HasStatusEffect("Surprised") then -- unit has already spotted someone and is now surprised, dont try to make it aware again
						local reason = T{Presets.AwareReasons.Default.arNotice.display_name, enemy = enemyUnit.Name, merc = unit.Nick or unit.Name}
						if enemyUnit:SetPendingAwareState("aware", reason, unit) then
							if not alerted then alerted = {} end
							alerted[#alerted + 1] = enemyUnit
							dbg_awareness_log(enemyUnit, " is alerted (combat-walk)")
						end
					end
				end
			end
		end
		if alertedPeople > 0 then
			unit:RemoveStatusEffect("Hidden")
		end
	else
		assert(false, string.format("unknown alert trigger '%s' used", tostring(trigger_type)))
	end

	if alerted then
		alerted = table.ifilter(alerted, function(idx, unit)
			return not unit.dummy and unit.pending_aware_state == "aware"
		end)
	end
	local alerted_count = alerted and #alerted or 0

	if alerted_count > 0 then
		local roles = {}
		PropagateAwareness(alerted, roles)
		for _, unit in ipairs(alerted) do
			if unit.pending_aware_state ~= "aware" and unit:SetPendingAwareState("aware") or roles[unit] == "alerter" then
				unit.pending_awareness_role = roles[unit] or "alerted"
			end
		end
	end
	
	if alerted_count + surprised > 0 then
		local pendingType = alerted_count > 0 and "alert" or "sus"
		if not g_UnitAwarenessPending or pendingType == "alert" then
			g_UnitAwarenessPending = pendingType
		end
	end

	return alerted_count + surprised, suspicious
end

function TriggerUnitAlert(trigger_type, ...)
	local alerted, suspicious = PushUnitAlert(trigger_type, ...)
	AlertPendingUnits()
	
	return alerted, suspicious
end

function PropagateAwareness(alerted_units, roles, killed_units)
	local i = 1	
	while i <= #alerted_units do
		local unit = alerted_units[i]
		local killed = killed_units and table.find(killed_units, unit)
		if not unit:IsDead() or killed then
			local upos = GetPackedPosAndStance(unit, killed and unit.killed_stance)
			local allies = GetAllAlliedUnits(unit)
			for _, ally in ipairs(allies) do
				if IsValidTarget(ally) and (ally.team.side == "neutral" or (not ally:IsAware() and ally.pending_aware_state ~= "aware")) then
					local apos = GetPackedPosAndStance(ally)
					local sight = ally:GetSightRadius(unit)
					if apos and upos and (stance_pos_dist(upos, apos) <= sight) and stance_pos_visibility(upos, apos) then
						table.insert_unique(alerted_units, ally)
						if roles then
							if not roles[unit] then
								roles[unit] = "alerter"
							end
							roles[ally] = "alerted"
						end
					end
				end
			end
		end
		i = i + 1
	end
end

local function ExecUnitAlert(reposition_units, alerted_by_enemy, first_unit)
	local sector = gv_Sectors[gv_CurrentSectorId]

	local unitList
	if next(alerted_by_enemy) then
		unitList = alerted_by_enemy
		alerted_by_enemy = table.ifilter(alerted_by_enemy, function(idx, unit) return IsValidTarget(unit) and IsValid(unit.alerted_by_enemy) and not unit:HasStatusEffect("Unconscious") end)
	else
		unitList = reposition_units
	end

	for _, unit in ipairs(unitList) do
		if sector.awareness_sequence == "Standard" then
			unit.pending_awareness_role = unit.pending_awareness_role or "alerted"
		else
			unit.pending_awareness_role = nil
		end
	end
	
	PlayBestCombatNotification(unitList)

	if not g_Combat then
		if not g_StartingCombat and g_Units and next(g_Units) then
			NetSyncEvent("ExplorationStartCombat", nil, first_unit and first_unit.session_id)
			WaitMsg("CombatStart")
		end
	elseif g_StartingCombat then
		WaitMsg("CombatStart")
	end
	
	-- Remove action camera if on.
	if ActionCameraPlaying then
		RemoveActionCamera(true)
		WaitMsg("ActionCameraRemoved", 5000)
	end
	
	-- start of combat setpiece (if needed)
	local cam_actor, restore_cam_obj
	if g_Combat and not g_Combat.unit_reposition_shown and alerted_by_enemy and #alerted_by_enemy > 0 and sector.awareness_sequence == "Standard" then		
		local unit 
		for _, u in ipairs(alerted_by_enemy) do
			if u.pending_awareness_role == "attacked" then
				unit = u
				break
			end
		end
		unit = unit or table.interaction_rand(alerted_by_enemy, "StartCombat")
		g_Combat.unit_reposition_shown = true
		cam_actor, restore_cam_obj = unit, unit.alerted_by_enemy
		if unit and not IsCompetitiveGame() then
			cameraTac.SetZoom(0,50)
			LockCamera(unit)
			Sleep(50)
			-- add all units to AlertedUnits group so the setpiece can access them
			for _, unit in ipairs(reposition_units) do
				if IsValid(unit) and not unit:IsDead() then
					unit:AddToGroup("AlertedUnits")
				end
			end
		
			-- go over setpieces and try to find the ones which can use their action camera
			local valid, all = {}, {}
			ForEachPresetInGroup("SetpiecePrg", "Combat", function(prg) -- todo: move to separate StartCombat group maybe?
				for _, cmd in ipairs(prg) do
					if IsKindOfClasses(cmd, "SetpieceActionCameraSingle", "SetpieceActionCamera") then
						local target
						if IsKindOf(cmd, "SetpieceActionCameraSingle") then
							target = SetpieceActionCameraSingle.CalcTarget({unit}, cmd.TargetOffset, cmd.TargetHeight, cmd.TargetAngleOffset)
						else
							target = unit.alerted_by_enemy
						end
						local pos, lookat, preset = SetpieceActionCamera.CalcCamera(unit, target, cmd.Preset, cmd.Position)
						if cmd.Preset == "Any" or preset == cmd.Preset then
							valid[#valid + 1] = prg.id
						end
						all[#all + 1] = prg.id		
					elseif IsKindOfClasses(cmd, "SetStartCombatAnim") then
						valid[#valid + 1] = prg.id			
					end
				end
			end)
			
			-- fall back to picking randomly if none of the available setpieces matched the current situation and rely on action cam fallback mechanism
			valid = (#valid > 0) and valid or all
			assert(#valid > 0)
			local setpiece = table.interaction_rand(valid, "StartCombat")
			
			-- pass the single unit in triggerUnits as it defines the camera for the setpiece
			local dlg = OpenDialog("XSetpieceDlg", false, {setpiece = setpiece, triggerUnits = {unit} })
			if dlg then
				while true do
					local ok, sp = WaitMsg("SetpieceEnded", 20 * 1000)
					if not ok or sp.id == dlg.setpiece then
						break
					end
				end
			end
			-- remove units from AlertedUnits group
			for _, unit in ipairs(reposition_units) do
				unit:RemoveFromGroup("AlertedUnits")
				unit.pending_awareness_role = nil
			end
			UnlockCamera(unit)
		end
	end
	
	CancelWaitingActions(-1)
	assert(#reposition_units > 0)
	assert(g_AIExecutionController and g_AIExecutionController.label == "AlertUnits")
	if not g_StartingCombat then 
		g_AIExecutionController.restore_camera_obj = restore_cam_obj or SelectedObj
	end
	g_AIExecutionController:Execute(reposition_units)
	
	-- special-case: if there are allied units who did not yet reposition in this combat, let them do it now - they're all aware anyway
	local team = table.find(g_Teams, "side", "ally")
	team = g_Teams[team or false]
	if g_Combat and team then
		local units = table.ifilter(team.units, function(idx, u) return not g_Combat:IsRepositioned(u) end)
		if #units > 0 then
			for _, unit in ipairs(units) do
				unit.pending_aware_state = "reposition"
			end
			g_AIExecutionController:Execute(units)
		end
	end

	g_UnitAlertThread = false
	g_UnitAwarenessPending = false
	DoneObject(g_AIExecutionController)
end

function AlertPendingUnits(sync_code)
	-- check buffering conditions
	--NetUpdateHash("AlertPendingUnits")
	if g_Combat then
		if g_Combat.start_of_turn or next(CombatActions_RunningState) ~= nil then
			return
		end
	end
	--[[
	NetUpdateHash("AlertPendingUnits_EarlyOuts", "GameState.entering_sector", GameState.entering_sector,
										"IsValidThread(g_UnitAlertThread)", IsValidThread(g_UnitAlertThread),
										"IsSetpiecePlaying()", IsSetpiecePlaying(),
										"GameState.setpiece_playing", GameState.setpiece_playing,
										"IsRadioBanterPlaying()", IsRadioBanterPlaying(),
										"g_AIExecutionController", not not g_AIExecutionController,
										"GameState.sync_loading", GameState.sync_loading,
										"not gv_Sectors", not gv_Sectors,
										"GameState.entering_sector", GameState.entering_sector)
	]]
	if IsSetpiecePlaying() or GameState.sync_loading or g_AIExecutionController or IsValidThread(g_UnitAlertThread) or gv_SatelliteView or not gv_Sectors then
		return
	end
	if GameState.entering_sector then
		return
	end
	
	--NetUpdateHash("AlertPendingUnits_doing_work")
	-- check for unit reactions to damage:
		-- pain: the alert will happen after it ends (triggered by the pain thread)
		-- dying: the alert will happen after it ends (triggered by UnitDied msg)
		-- thrown off by explosion: the alert will happen after it ends (triggered by Idle or UnitDied msg)
	for _, unit in ipairs(g_Units) do
		--if IsValidThread(unit.pain_thread) or (unit:IsDead() and not unit:IsIdleCommand()) or unit.command == "ExplosionFly" then
		if IsValidThread(unit.pain_thread) or unit.command == "ExplosionFly" then
			return
		end
		
		if HasCombatActionInProgress(unit) then
			if g_Combat then
				return
			else -- In exploration block the alert only if the current action is an attack
				if not unit:IsInterruptable() then return end
				
				local action_id = CombatActions_UnitAction[unit]
				local action = action_id and CombatActions[action_id]
				if action and (action.ActionType == "Ranged Attack" or action.ActionType == "Melee Attack") then
					return
				end
			end
		end
	end
	
	local sector = gv_Sectors[gv_CurrentSectorId]
	-- only enum the units with pending awareness, the execution controller will process them
	local reposition_units, alerted_by_enemy, surprised_units
	local start_combat, first_unit
	local current_team = g_Combat and g_Teams[g_CurrentTeam]
	local end_combat = g_Combat and g_Combat:ShouldEndCombat()
	local skip_all = sector and sector.awareness_sequence == "Skip All"

	for _, team in ipairs(g_Teams) do
		if end_combat or team.side == "neutral" then
			for _, unit in ipairs(team.units) do
				unit.pending_aware_state = nil
			end
		else
			for _, unit in ipairs(team.units) do
				local state = unit.pending_aware_state
				if state then
					if not IsValidTarget(unit) then
						unit.pending_aware_state = nil
					elseif unit:IsAware() then
						unit.pending_aware_state = nil
					elseif state == "aware" then
						start_combat = true
						if skip_all or team == current_team then
							unit:RemoveStatusEffect("Unaware")
							unit:RemoveStatusEffect("Surprised")
							unit:RemoveStatusEffect("Suspicious")
							unit.pending_aware_state = nil
						else
							if not reposition_units then reposition_units = {} end
							reposition_units[#reposition_units + 1] = unit
							first_unit = first_unit or unit.alerted_by_enemy
							if IsValidTarget(unit.alerted_by_enemy) then
								if not alerted_by_enemy then alerted_by_enemy = {} end
								alerted_by_enemy[#alerted_by_enemy + 1] = unit
							end
						end
					elseif state == "surprised" then
						start_combat = true
						if g_Combat then
							unit:AddStatusEffect("Surprised")
							unit:RemoveStatusEffect("Suspicious")
							unit.pending_aware_state = nil
						else
							if not surprised_units then surprised_units = {} end
							surprised_units[#surprised_units + 1] = unit
						end
					elseif state == "suspicious" then -- becoming suspicious is instant (no action involved),	 so do it directly
						unit:AddStatusEffect("Suspicious")
						unit:RemoveStatusEffect("Unaware")
						unit.pending_aware_state = nil
					else
						unit.pending_aware_state = nil
					end
				end
			end
		end
	end
	if start_combat and not g_Combat or reposition_units then
		if reposition_units then
			CreateAIExecutionController{label = "AlertUnits", reposition = true} -- create controller first so nobody else does before the thread starts
		end

		g_UnitAlertThread = CreateGameTimeThread(function(sync_code, first_unit, reposition_units, alerted_by_enemy)
			if not g_Combat then
				if not g_StartingCombat and g_Units and next(g_Units) then
					if sync_code then
						CreateGameTimeThread(NetSyncEvents.ExplorationStartCombat, nil, first_unit and first_unit.session_id)
					else
						NetSyncEvent("ExplorationStartCombat", nil, first_unit and first_unit.session_id)
					end
				end
				while not g_Combat do
					WaitMsg("CombatStarting", 10)
				end
				-- now that we're in combat recheck the units for pending Surprised status
				for _, team in ipairs(g_Teams) do
					if team.side ~= "neutral" then
						for _, unit in ipairs(team.units) do
							if unit.pending_aware_state == "surprised" and IsValidTarget(unit) and not unit:IsAware() then
								unit:AddStatusEffect("Surprised")
								unit.pending_aware_state = nil
							end
						end
					end
				end
			end
			if reposition_units then
				ExecUnitAlert(reposition_units, alerted_by_enemy, first_unit)
			end
		end, sync_code, first_unit, reposition_units, alerted_by_enemy)
	end

	if not reposition_units then
		if surprised_units then
			PlayBestCombatNotification(surprised_units)
		end
		g_UnitAwarenessPending = false
	end
end

MapGameTimeRepeat("AlertPendingUnits", 100, AlertPendingUnits)

function OnMsg.VisibilityUpdate()
	if not g_Combat then return end -- out of combat this is handled by UpdateSuspicion
	
	local aware = {}
	for _, unit in ipairs(g_Units) do	
		if not unit:IsDead() then
			local is_aware = unit:IsAware()
			if is_aware and not unit.team.player_team and unit.team.side ~= "neutral" then
				aware[#aware + 1] = unit
			end
			for _, seen in ipairs(g_Visibility[unit]) do
				if IsValid(seen) and unit:IsOnEnemySide(seen) then
					unit.last_known_enemy_pos = seen:GetPos()
					if not is_aware then
						PushUnitAlert("sight", unit, seen)
					end
				end
			end
		end
	end
	
	-- PropagateAwareness from the already aware units to reflect sight/visuals changes
	PropagateAwareness(aware)
	for _, unit in ipairs(aware) do
		if unit:SetPendingAwareState("aware") then
			unit.pending_awareness_role = "alerted"
		end
	end
	
	AlertPendingUnits()
end

function DeadUnitsPulse()
	if IsSetpiecePlaying() then return end

	local alert_team_units
	for _, team in ipairs(g_Teams) do
		local neutral = team.neutral
		local alert_side = neutral and "enemy1" or team.side
		local alert_units = alert_team_units and alert_team_units[alert_side]
		for _, unit in ipairs(team.units) do
			if (not neutral or unit.neutral_retaliate) and unit:IsDead() then
				if not alert_units then
					for _, team in ipairs(g_Teams) do
						if team.side == alert_side then
							for _, u in ipairs(team.units) do
								if not u:IsDead()
									and not u:IsAware("pending")
									and not u:HasStatusEffect("HighAlert")
									and not u:HasStatusEffect("IgnoreBodies")
								then
									if not alert_units then alert_units = {} end
									alert_units[#alert_units + 1] = u
								end
							end
						end
					end
					if not alert_team_units then alert_team_units = {} end
					alert_team_units[alert_side] = alert_units or empty_table
				end
				if not alert_units or #alert_units == 0 then
					break
				end
				PushUnitAlert("dead body", unit, alert_units)
			end
		end
	end
	AlertPendingUnits()
end

OnMsg.TurnStart = DeadUnitsPulse
OnMsg.UnitDied = DeadUnitsPulse
OnMsg.Idle = AlertPendingUnits

function OnMsg.CombatActionEnd()
	CreateGameTimeThread(AlertPendingUnits)
end

function OnMsg.CombatEnd()
	MapForEach("map", "Unit", function(unit) 
		unit.pending_aware_state = nil
		unit.alerted_by_enemy = nil
		unit.aware_reason = nil
	end)
	dbg_awareness_log("Combat ended")
end

function OnMsg.CombatStart()
	dbg_awareness_log("Combat started")
end

function OnMsg.EnterSector()
	g_AwarenessLog = {}
end

MapGameTimeRepeat("DeadAwarenessPulseTick", 500, function()
	if not g_Combat then
		DeadUnitsPulse()
	end
end)

function Unit:SetPendingAwareState(state, reason, alerter)
	NetUpdateHash("SetPendingAwareState", state, alerter, self.pending_aware_state, self:IsAware())
	if self.dummy or self.team.side == "neutral" or self:IsDead() or self:IsAware() then return end
	
	if reason and (not self.aware_reason or table.find(Presets.AwareReasons.Default, "display_name", reason[1]) > table.find(Presets.AwareReasons.Default, "display_name", self.aware_reason[1])) then
		self.aware_reason = reason or self.aware_reason
	end
	if not self.pending_aware_state or (state == "aware" or state == "surprised") and self.pending_aware_state ~= "aware" then
		self.pending_aware_state = state
		self.alerted_by_enemy = alerter or self.alerted_by_enemy
		return true
	elseif self.pending_aware_state == state then
		self.alerted_by_enemy = self.alerted_by_enemy or alerter
		return true
	end
end

-- Suspicious

function Unit:SuspiciousRoutine()
	local def = CharacterEffectDefs.Suspicious
	
	-- wait to finish the previouse job
	Sleep(self:TimeToAngleInterpolationEnd())

	local body = self.suspicious_body_seen and HandleToObject[self.suspicious_body_seen]
	if IsValid(body) then
		self:Face(body, not GameTimeAdvanced and 0 or 500)
	end

	local anim = self:TryGetActionAnim("Suspicious", "Standing")
	if anim and self:GetStateText() ~= anim then
		self:SetState(anim)
	end
	
	local last_update = GameTime()
	local effect = self:GetStatusEffect("Suspicious")
	local time = effect:ResolveValue("suspicious_time") or 0
	effect:SetParameter("suspicious_time", time)
	
	local grow_time = effect:ResolveValue("sight_grow_time")
	local sight_mod_max = effect:ResolveValue("sight_modifier_max")
	local delay_time = effect:ResolveValue("max_sight_delay")
	local shrink_time = effect:ResolveValue("sight_shrink_time")
	
	repeat		
		WaitMsg("CombatStarting", 100) -- so it breaks when on combat start
		effect = self:GetStatusEffect("Suspicious") -- recheck in case it was lost meanwhile
		if g_Combat or not effect then break end
		
		-- accumulate time spent looking around
		local time_now = GameTime()
		time = time + time_now - last_update
		last_update = time_now
		effect:ResolveValue("suspicious_time", time)
		
		-- update sight radius mod in effect value
		local mod
		if time < grow_time then
			mod = MulDivRound(sight_mod_max, time, grow_time)
		elseif time < grow_time + delay_time then
			mod = sight_mod_max
		elseif time < grow_time + delay_time + shrink_time then
			local t = time - grow_time - delay_time
			mod = MulDivRound(sight_mod_max, shrink_time - t, shrink_time)
		else
			mod = nil
		end
		if effect:ResolveValue("suspicious_sight_mod") ~= mod then
			effect:SetParameter("suspicious_sight_mod", mod)
			InvalidateUnitLOS(self)
		end
	until not mod
	self.suspicious_body_seen = nil
	if not g_Combat and not g_StartingCombat and self:HasStatusEffect("Suspicious") then 
		local enemies = GetAllEnemyUnits(self)
		local mindist
		for _, enemy in ipairs(enemies) do
			if enemy.team and enemy.team.player_team then
				local dist = self:GetDist(enemy)
				mindist = Min(mindist or dist, dist)
			end
		end
		-- become aware or revert to unaware based on min distance to enemy
		if mindist and mindist < Suspicious:ResolveValue("remain_unaware_min_dist")*guim then
			TriggerUnitAlert("surprise", self, "suspicious")
		else
			self:AddStatusEffect("Unaware")
		end
	end
end

-- Reposition

local function PathFromContextDest(unit, context, dest)
	for _, stance in ipairs(StancesList) do
		local cpath = context.combat_paths[stance]
		local path = cpath and cpath:GetCombatPathFromPos(dest)
		if path then
			return path
		end
	end
end

function Unit:ClaimRepositionMarker()
	local rep_markers = MapGetMarkers("Reposition", nil, function(marker, unit)
		if g_RepositionMarkersClaimed[marker] then
			return false
		end
		if (marker.TargetUnits or "") ~= "" and marker.TargetUnits ~= unit.unitdatadef_id then
			return false
		end
		local x, y, z = GetPassSlabXYZ(marker)
		if not x then
			return false
		end
		local has_path
		local dest = point_pack(x, y, z)
		for _, stance in ipairs(StancesList) do
			local cpath = unit.ai_context.combat_paths[stance]
			if cpath and cpath:GetAP(dest) then
				has_path = true
				break
			end
		end
		return 
			has_path and 
			CanOccupy(unit, x, y, z) and 
			marker:IsMarkerEnabled()
	end, self)
	if not rep_markers or #rep_markers == 0 then
		return
	end
	local idx = 1 + self:Random(#rep_markers)
	return rep_markers[idx]
end

function Unit:PickRepositionDest()
	local context = self.ai_context
	local behavior = context and context.behavior
	context.reposition = true
	context.forced_run = true
	context.ai_destination = false
	self.reposition_dest = false
	self.reposition_marker = false

	if not behavior then return end

	local marker = self:ClaimRepositionMarker()

	if marker then
		g_RepositionMarkersClaimed[marker] = true
		local x, y, z = GetPassSlabXYZ(marker)
		self.reposition_path = PathFromContextDest(self, context, point_pack(x, y, z))
		-- there could be no path when the unit is on the destination
		if self.reposition_path then
			self.reposition_dest = stance_pos_pack(x, y, z, self.stance)
			self.reposition_marker = marker
		end
	else
		behavior:Think(self)
		if context.ai_destination then
			local x, y, z = stance_pos_unpack(context.ai_destination)
			assert(CanOccupy(self, x, y, z))
			self.reposition_path = PathFromContextDest(self, context, point_pack(x, y, z))
			-- there could be no path when the unit is on the destination
			if self.reposition_path then
				self.reposition_dest = context.ai_destination
			end
		end
	end
end

function Unit:GetProvokePos(path, visible_only)
	local goto_dummies = self:GenerateTargetDummiesFromPath(path)
	local interrupts, provoke_idx = self:CheckProvokeOpportunityAttacks(CombatActions.Move, "move", goto_dummies, visible_only)
	local provoke_pos = provoke_idx and goto_dummies[provoke_idx].pos
	return provoke_pos
end

function Unit:Reposition()
	assert(g_Combat and self:IsAware())
	local always_ready = g_AIExecutionController and g_AIExecutionController.label == "AlwaysReady" and g_AIExecutionController.activator == self
	if not g_Combat or (not always_ready and g_Combat:IsRepositioned(self)) or self:HasStatusEffect("Unconscious") then
		return
	end

	-- initialization/startup
	g_Combat:SetRepositioned(self, true)
	self:PushDestructor(function()
		self.reposition_dest = nil
		self.reposition_path = nil
		self.reposition_marker = nil
	end)

	if not g_Combat or (self.team == g_Teams[g_CurrentTeam] and not always_ready) or (self.team.side == "neutral") or self.dummy then
		self:PopAndCallDestructor()
		return
	end

	local context = self.ai_context
	local path = self.reposition_path

	if path then
		-- set target dummy to free the current position (random waits could chnage the move order)
		local destination = point(point_unpack(path[1]))
		self:SetTargetDummyFromPos(destination, nil, true)
		self:ClearEnumFlags(const.efResting)
		Sleep(0)

		-- debug code
		local x, y, z = point_unpack(path[1])
		local o = GetOccupiedBy(x, y, z, self)
		if o and o ~= self then
			printf("Unit %d reposition to %s is occupied by another unit %d (%s)", self.handle, tostring(point(point_unpack(path[1]))), o.handle, o.command)
			printf("Unit current pos      %s, efResting = %d", tostring(self:GetPos()), self:GetEnumFlags(const.efResting))
			if self.target_dummy then
				printf("Unit target dummy pos %s, efResting = %d, locked = %s", tostring(self.target_dummy:GetPos()), self.target_dummy:GetEnumFlags(const.efResting), tostring(self.target_dummy.locked))
			end
			printf("Unit reposition_dest  %s", self.reposition_dest and tostring(point(stance_pos_unpack(self.reposition_dest))) or "")
			if self.ai_context and self.ai_context.ai_destination then
				printf("Unit ai_destination   %s", tostring(point(stance_pos_unpack(self.ai_context.ai_destination))))
			end
			printf("Other command: %s", o.command)
			printf("Other current pos      %s, efResting = %d", tostring(o:GetPos()), o:GetEnumFlags(const.efResting))
			if o.target_dummy then
				printf("Other target dummy pos %s, efResting = %d, locked = %s", tostring(o.target_dummy:GetPos()), o.target_dummy:GetEnumFlags(const.efResting), tostring(o.target_dummy.locked))
			end
			if o.reposition_dest then
				printf("Other reposition_dest  %s", tostring(point(stance_pos_unpack(o.reposition_dest))))
			end
			if o.ai_context and o.ai_context.ai_destination then
				printf("Other ai_destination   %s", tostring(point(stance_pos_unpack(o.ai_context.ai_destination))))
			end
			assert(false, "Unit reposition")
		end

		-- check for interrupts first and move to PostAction early if there are none to enable all units to play their Awareness anims simultaneously
		if not self:GetProvokePos(path) then
			SetCombatActionState(self, "PostAction")
		end

		if self.pending_awareness_role then
			self:PlayAwarenessAnim() -- PlayAwarenessAnim has built-in randomized starting time when applicable
		else
			-- randomize starting time to avoid total sync when multiple units reposition
			Sleep(self:Random(500))
		end

		self:CombatGoto("Move", self.ActionPoints, nil, path, true)
	else
		-- no movement, just move to PostAction
		SetCombatActionState(self, "PostAction")
		if self.pending_awareness_role then
			self:PlayAwarenessAnim() -- PlayAwarenessAnim has built-in randomized starting time when applicable
		end
	end
	self:SetTargetDummyFromPos(nil, nil, true)
	if string.match(self:GetStateText(), ".*_Combat%a+Stop.*") then
		Sleep(self:TimeToAnimEnd())
	end
	local base_idle = self:GetIdleBaseAnim(self.target_dummy.stance)
	if not IsAnimVariant(self:GetStateText(), base_idle) then
		self:PlayTransitionAnims(base_idle, self.target_dummy:GetAngle())
	end
	self:AnimatedRotation(self.target_dummy:GetAngle(), base_idle)
	self:SetRandomAnim(base_idle)

	-- end/cleanup
	self:PopAndCallDestructor()
end

function Unit:RepositionOpeningAttack()
	local context = self.ai_context
	if not context then return end

	local dest = GetPackedPosAndStance(self)
	local target
	if dest then
		-- recalc target to make sure we're firing at a valid target, giving enough AP for one basic attack
		self.ActionPoints = context.default_attack_cost
		context.dest_ap[dest] = self.ActionPoints
		context.reposition = false -- re-enable damage score
		AIPrecalcDamageScore(context, {dest})
		target = (context.dest_target or empty_table)[dest]
	end
	
	if IsKindOf(target, "Unit") and IsValidTarget(target) then
		local chance 
		if self.AlwaysUseOpeningAttack then
			chance = 100
		else
			chance = Max(0, 30 + self.Dexterity - target.Dexterity)
			chance = self:CallReactions_Modify("OnCalcOpeningAttackChance", chance, self, target)
			chance = target:CallReactions_Modify("OnCalcOpeningAttackChance", chance, self, target)
			chance = Clamp(chance, 0, 100)
		end
		
		if self:Random(100) < chance then
			local weapon = context.default_attack:GetAttackWeapons(self)
			if IsKindOf(weapon, "Firearm") and not IsKindOfClasses(weapon, "HeavyWeapon", "FlareGun") then
				local attacked
				local opening_attack = self.OpeningAttackType
				if opening_attack ~= "Default" then
					if CombatActions[opening_attack]:GetUIState({self}) == "hidden" then
						opening_attack = false -- revert to basic attack
					end
				end
				
				if opening_attack == "Overwatch" then
					local args, has_ap = AIGetAttackArgs(context, CombatActions.Overwatch, nil, "None")
					if has_ap then 	
						local zones = AIPrecalcConeTargetZones(context, "Overwatch")
						local zone = AIEvalZones(context, zones, 1, 1)
						if zone then
							-- We're already running a Combat Action so we can't use AIPlayCombatAction.
							-- We also shouldn't call OverwatchAction() directly as it will break the execution of the current command in the end.
							-- Instead, we exploit OverwatchAction being a combat behavior and set it directly as one, relying on the code to be
							--		executed once the current command concludes and the we go into Idle.
							local args = { target = zone.target_pos }
							self:SetCombatBehavior("OverwatchAction", {"Overwatch", 0, args})
							attacked = true
						end
					end
				elseif opening_attack == "PinDown" then
					local args, has_ap, target = AIGetAttackArgs(context, CombatActions.PinDown, nil, "None")
					if has_ap and IsValidTarget(target) then
						if self:HasPindownLine(target, "Torso") then
							-- Same as above, set the behavior directly.
							local arg = { target = target, target_spot_group = "Torso" },
							self:SetCombatBehavior("PinDown", {"PinDown", 0, args})
							attacked = true
						end
					end
				end
				if not attacked then
					self:FirearmAttack(context.default_attack.id, 0, {target = target, opportunity_attack = true, opening_attack = true})
				end
			elseif IsKindOf(weapon, "MeleeWeapon") then
				self:MeleeAttack(context.default_attack.id, 0, { target = target, opportunity_attack = true})
			end
		end
	end
	
	-- take cover chance
	local archetype = self:GetCurrentArchetype()
	local chance = 0
	for _, behavior in ipairs(archetype.Behaviors) do
		if behavior:MatchUnit(self) then
			chance = Max(chance, behavior.TakeCoverChance)
		end
	end
	if self:Random(100) < chance then
		self:TakeCover()
	end

	self:RemoveStatusEffect("OpeningAttackBonus")	
end

function IsRepositionPhase()
	return g_AIExecutionController and g_AIExecutionController.reposition
end

-- noise sources debug
if Platform.developer or Platform.debug then
	function ToggleNoiseSources()
		if not g_NoiseSources then return end
		
		if g_NoiseSources.shown then
			for _, obj in ipairs(g_NoiseSources.shown) do
				DoneObject(obj)
			end
			g_NoiseSources.shown = nil
			printf("displayed noise sources removed")
		else
			local list, centers, ranges = {}, {}, {}
			local avg_pos = point30
			
			for i, descr in ipairs(g_NoiseSources) do
				local obj = PlaceObject("Object")
				obj:ChangeEntity("MarkerMusic")
				obj:SetScale(20)
				obj:SetPos(descr.pos)
				
				local text = Text:new()
				text:SetText(string.format("%s (%d)", descr.actor.unitdatadef_id, descr.noise))
				obj:Attach(text)
				
--[[				local mesh = Mesh:new()		
				local mesh_str = pstr("", 1024)
				AppendVerticesUnionCircles({descr.pos}, {descr.noise * const.SlabSizeX}, mesh_str)
				mesh:SetMeshFlags(const.mfWorldSpace)
				mesh:SetMesh(mesh_str)
				mesh:SetShader(ProceduralMeshShaders.enemy_aware_range)
				obj:Attach(mesh)--]]
				
				centers[i] = descr.pos
				ranges[i] = descr.noise * const.SlabSizeX
				list[i] = obj				
			end
			
			if #list > 0 then
---[[				
				local mesh = Mesh:new()		
				local mesh_str = pstr("", 1024)
				AppendVerticesUnionCircles(centers, ranges, mesh_str)
				mesh:SetMeshFlags(const.mfWorldSpace)
				mesh:SetMesh(mesh_str)
				mesh:SetShader(ProceduralMeshShaders.enemy_aware_range)
				mesh:SetColorFromTextStyle("EnemyAwareRange")
				mesh:SetPos(avg_pos:SetInvalidZ() / #list)
				list[#list + 1] = mesh--]]
				g_NoiseSources.shown = list
			end
			printf("%d logged noise sources displayed", #g_NoiseSources)
		end
	end
	
	function ResetNoiseSources()
		if not g_NoiseSources then return end
		if g_NoiseSources.shown then
			ToggleNoiseSources()
		end
		g_NoiseSources = {}
	end
	
	--OnMsg.NewCombatTurn = ResetNoiseSources
	OnMsg.CombatStart = ResetNoiseSources
end

-- Suspicion

SuspicionThreshold = 160 -- Above this much the unit will become alerted
local lSuspicionTickRate = 100 -- How often to add the tick amount
local lSuspicionTickAmount = 10 -- The amount to add when hidden
local lSuspicionTickAmountProjector = 6 -- The amount to add when hidden
ProjectorSuspiciousApplyRange = 10 * const.SlabSizeX -- Enemies within this distance of the projector will be alerted
local lSuspicionTickAmountProne = 5 -- The amount to add when hidden and in prone
local lSuspicionTickAmountNotHidden = 16 -- The amount to add when not hidden
local lSuspicionTickDownAmount = 2 -- The amount to remove when no unit is in range
local lSuspicionTickMinDist = const.SlabSizeX * 2 -- If this close to an enemy then frontness doesn't matter (unless hidden or in the dark)
local lSuspicionTickDistanceModOuter = const.SlabSizeX * 4 -- Past this distance in the sight radius the distance modifier is 100%
local lCubicInIndex = GetEasingIndex("Cubic in")

MapVar("lastSusUpdate", 0)

function OnMsg.CombatEnd()
	for i, u in ipairs(g_Units) do
		u.suspicion = 0
	end
	Msg("CombatEndAfterAwarenessReset")
end

function UpdateSuspicion(alliedUnits, enemyUnits, intermediate_update)
	if GameTime() - lastSusUpdate < lSuspicionTickRate then return end
	--NetUpdateHash("UpdateSuspicion", alliedUnits, enemyUnits, intermediate_update)

	local sneakLights
	if intermediate_update then
		sneakLights = GetSneakProjectorLights()
	end

	local sector = gv_Sectors[gv_CurrentSectorId]
	local anySusUpdated = false
	local susIncreasedBy = {}
	for i, ally in ipairs(alliedUnits) do
		ally.suspicion = ally.suspicion or 0
		
		-- Performing an attack or something
		if not ally:IsIdleCommand() and not ally:IsInterruptable() then
			goto continue
		end
		if not IsValid(ally) or ally:IsDead() then goto continue end
		
		local allyDetectionModifier = 100
		if HasPerk(ally, "Untraceable") then
			allyDetectionModifier = allyDetectionModifier - Untraceable:ResolveValue("enemy_detection_reduction")
		end
		if ally:HasStatusEffect("Darkness") then
			allyDetectionModifier = allyDetectionModifier + const.EnvEffects.DarknessDetectionRate
		end
		allyDetectionModifier = Max(0, allyDetectionModifier)

		local raiseSusLargest = 0
		local raiseSusEnemy = false
		local max_sight_radius = MulDivRound(GetMaxSightRadius(), 1200, 1000)
		for i, enemy in ipairs(enemyUnits) do
			if enemy.retreating then goto continue end
			if enemy.command == "ExitCombat" then goto continue end
			if enemy:IsDead() then goto continue end
			if not IsCloser(enemy, ally, max_sight_radius) then
				goto continue
			end
			local seesAlly = HasVisibilityTo(enemy, ally)
			-- try to skip GetSightRadius calculations
			if not seesAlly then
				if raiseSusEnemy or not HasVisibilityTo(ally, enemy) then
					goto continue
				end
			end
			local sightRad, hidden, darkness = enemy:GetSightRadius(ally)
			local dist = enemy:GetDist(ally)
			local inRad = dist <= sightRad
			if inRad then
				if seesAlly then
					-- If in front of any enemy, add a bonus detection %
					-- If in the behind plane then have a smaller cut off.
					local angle_to_object = AngleDiff(CalcOrientation(enemy, ally), enemy:GetOrientationAngle())
					if abs(angle_to_object) < 90*60 then
						local radiusLess = MulDivRound(sightRad, 80, 100)
						if dist > radiusLess then
							goto continue
						end
					end
					
					-- The larger this is, the closer the ally is to the enemy
					local distFromSightRad = sightRad - dist 
					
					-- Decrease sus the further away you are
					local distanceModifier = false
					if distFromSightRad < lSuspicionTickDistanceModOuter then
						distanceModifier = Lerp(10, 100, distFromSightRad, sightRad)
					else
						distanceModifier = 100
					end
					
					-- Modify the value based on how in front you are
					local frontnessModifier = false
					local maxDot = (4096 * 4096) * 2
					local dot = cos(angle_to_object) * 4096
					dot = EaseCoeff(lCubicInIndex, dot + 4096 * 4096, maxDot)
					frontnessModifier = Lerp(hidden and 30 or 40, 100, dot, maxDot)
					
					local closeInTheLight = false
					if hidden and not darkness and dist < lSuspicionTickMinDist and frontnessModifier > 60 then
						closeInTheLight = true
					end
					
					-- Get the base value based on a variety of factors
					local value = 0
					if hidden and not closeInTheLight then
						if ally.stance == "Prone" then
							value = lSuspicionTickAmountProne
						else
							value = lSuspicionTickAmount
						end
					else
						value = lSuspicionTickAmountNotHidden
					end
					
					value = MulDivRound(value, distanceModifier, 100)
					value = MulDivRound(value, frontnessModifier, 100)
					
					if value > raiseSusLargest then
						raiseSusEnemy = enemy
						raiseSusLargest = value
					end
				end
			elseif not raiseSusEnemy and HasVisibilityTo(ally, enemy) then
				local extraRad = MulDivRound(sightRad, 1200, 1000)
				if dist <= extraRad then
					raiseSusEnemy = enemy
				end
			end

			::continue::
		end
		
		if sneakLights and IsMerc(ally) then
			local lightIndex = IsVoxelIlluminatedByObjects(ally:GetPos(), sneakLights)
			local val = lightIndex ~= 0 and lSuspicionTickAmountProjector or 0
			if val > raiseSusLargest then
				raiseSusLargest = val
				
				local light = sneakLights[lightIndex]
				local originalLight = light and light.original_light
				local projector = originalLight and originalLight:GetParent()
				if projector then
					raiseSusEnemy = projector
				end
				
				if ally.suspicion + raiseSusLargest >= SuspicionThreshold and ally:HasStatusEffect("Hidden") then
					ally:RemoveStatusEffect("Hidden")
					-- In this specific case we want this status effect to have a removal message.
					-- Copied from AddStatusEffect's floating text.
					CreateMapRealTimeThread(function()
						WaitPlayerControl()
						CreateFloatingText(ally, T{488962074575, "- <DisplayName>", Hidden}, nil, nil, true)
					end)

					PushUnitAlert("projector", ally, enemyUnits, projector)
				end
			end
		end
		
		local oldSus = ally.suspicion
		if raiseSusLargest > 0 then
			ally.suspicion = ally.suspicion + raiseSusLargest
			susIncreasedBy[#susIncreasedBy + 1] = { unit = raiseSusEnemy, amount = raiseSusLargest, sees = ally }
		else
			ally.suspicion = ally.suspicion - lSuspicionTickDownAmount
			if raiseSusEnemy then
				susIncreasedBy[#susIncreasedBy + 1] = { unit = raiseSusEnemy, amount = -1, sees = ally }
			end
		end
		ally.suspicion = Clamp(ally.suspicion, 0, SuspicionThreshold)
		
		if ally.suspicion ~= oldSus and ally.ui_badge then
			local wasZeroNowIsnt = oldSus == 0 and ally.suspicion > 0
			local wasntZeroNowIs = oldSus > 0 and ally.suspicion == 0
			if wasZeroNowIsnt or wasntZeroNowIs then
				ally.ui_badge:UpdateActive()
				anySusUpdated = true
			end
		end
		
		if ally.suspicion >= SuspicionThreshold then
			if sector.warningStateEnabled and not sector.warningReceived then
				EnterWarningState(enemyUnits, alliedUnits, ally)
				anySusUpdated = true
				break
			else
				TriggerUnitAlert("discovered", ally)
				return
			end
		end
		
		::continue::
	end
	
	if anySusUpdated then
		local igi = GetInGameInterfaceModeDlg()
		if igi.crosshair then
			igi.crosshair:UpdateBadgeHiding()
		end
	end
	
	if not intermediate_update then
		lastSusUpdate = GameTime()
	end
	
	return susIncreasedBy
end

MapVar("g_CombatStartDetectedVR", false)
function CombatStarDetectedtVR(unit)
	if (not g_Combat or g_Combat:ShouldEndCombat()) and unit:IsMerc() then
		if unit:HasStatusEffect("Hidden") then
			g_CombatStartDetectedVR = true
			PlayVoiceResponse(unit, "CombatStartDetected")
		end
	end
end

function OnMsg.CombatStart()
	if not g_CombatStartDetectedVR and g_Combat.starting_unit then
		if g_LastAttackStealth then
			PlayVoiceResponse(g_Combat.starting_unit, "CombatStartDetected")
		else
			PlayVoiceResponse(g_Combat.starting_unit, "CombatStartPlayer")
		end
	end
end

function OnMsg.CombatEnd()
	g_CombatStartDetectedVR = false
end

function OnMsg.UnitMovementDone(self, action_id)
	if not g_Combat or not self.team.player_team or self.command == "EnterCombat" then return end
	if action_id ~= "Move" then -- Attacks which include movement will push the "attack" alert.
		return
	end

	PushUnitAlert("discovered", self)
end

-- Warning State
MapVar("WarningStateEnemies", {})

function EnterWarningState(enemyUnits, alliedUnits, triggeringUnit)
	local sector = gv_Sectors[gv_CurrentSectorId]
	if sector.inWarningState then return end -- already in Warning State

	WarningStateEnemies = enemyUnits
	
	-- Try to play a Banter from the nearest enemy to the unit that triggered the Warning
	local warningBanters = sector.warningBanters or {}
	if #warningBanters > 0 then 
		local nearestEnemy = GetNearestEnemy(triggeringUnit)
		local banters, actors = FilterAvailableBanters(warningBanters, nil, {nearestEnemy})
		if banters then
			local idx = InteractionRand(#banters, "PlayBanterEffect") + 1
			
			-- Stop and look at triggering unit
			local lookAt = CalcOrientation(nearestEnemy, triggeringUnit) or nearestEnemy:GetAngle()
			nearestEnemy:SetBehavior()
			nearestEnemy:SetCommand("Idle")
			nearestEnemy:SetAngle(lookAt)
			
			PlayBanter(banters[idx], actors[idx])
		end
	end
	
	for _, enemy in ipairs(WarningStateEnemies) do
		enemy:SetSide("neutral")
	end

	local timerId = "warningTimer_" .. sector.Id
	local timerText = sector.warningTimerText or T(243197217972, "Exit the Area")
	TimerCreate(timerId, timerText, sector.warningStateTimer)
	
	-- Reset Suspicion
	for _, unit in ipairs(alliedUnits) do
		unit.suspicion = 0
	end
	
	sector.warningReceived = true
	sector.inWarningState = true
	
	Msg("OnEnterWarningState")
	ExecuteSectorEvents("SE_OnEnterWarningState", sector.Id)
end

function EndWarningState()
	local sector = gv_Sectors[gv_CurrentSectorId]
	if not sector or not sector.inWarningState then return end -- not in Warning State
		
	for _, enemy in ipairs(WarningStateEnemies) do
		enemy:SetSide("enemy1")
	end
	
	local timerId = "warningTimer_" .. sector.Id
	TimerDelete(timerId) -- might be better to move this after
	
	sector.inWarningState = false
end

function OnMsg.OnAttack(attacker)
	if IsMerc(attacker) then
		local sector = gv_Sectors[gv_CurrentSectorId]
		if sector.inWarningState then
			EndWarningState()
			attacker.suspicion = SuspicionThreshold
		end
	end
end

function OnMsg.TimerFinished(timerId)
	if string.starts_with(timerId, "warningTimer_") then
		EndWarningState()
	end
end

function OnMsg.AllSquadsLeftSector() -- this might not be the ideal place to do the clean up
	EndWarningState()
end

function OnMsg.CampaignTimeAdvanced()
	EndWarningState()
end

function PlayBestCombatNotification(units)
	local bestReason = false
	local bestUnitIdx = 0
	for idx, unit in ipairs(units or empty_table) do
		if not bestReason then
			bestReason = unit.aware_reason
			bestUnitIdx = idx
		elseif unit.aware_reason and table.find(Presets.AwareReasons.Default, "display_name", unit.aware_reason[1]) > table.find(Presets.AwareReasons.Default, "display_name", bestReason[1]) then
			bestReason = unit.aware_reason
			bestUnitIdx = idx
		end
	end
	if bestReason then
		local unit = units[bestUnitIdx]
		local isAlly = unit and unit:IsPlayerAlly()
		ShowTacticalNotification(isAlly and "allyAwareReason" or "awareReason", false, bestReason)
	end
end