-
-
Notifications
You must be signed in to change notification settings - Fork 988
/
battle_calcs.lua
1605 lines (1361 loc) · 72.8 KB
/
battle_calcs.lua
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
local H = wesnoth.require "helper"
local AH = wesnoth.require "ai/lua/ai_helper.lua"
local LS = wesnoth.require "location_set"
local M = wesnoth.map
-- This is a collection of Lua functions used for custom AI development.
-- Note that this is still work in progress with significant changes occurring
-- frequently. Backward compatibility cannot be guaranteed at this time in
-- development releases, but it is of course easily possible to copy a function
-- from a previous release directly into an add-on if it is needed there.
local battle_calcs = {}
function battle_calcs.unit_attack_info(unit, cache)
-- Return a table containing information about attack-related properties of @unit
-- The result can be cached if variable @cache is given
-- This is done in order to avoid duplication of slow processes
-- Return table has fields:
-- - attacks: the attack tables
-- - resist_mod: resistance modifiers (multiplicative factors) index by attack type
-- - alignment: just that
-- Set up a cache index. We use id+max_hitpoints+side, since the
-- unit can level up. Side is added to avoid the problem of MP leaders sometimes having
-- the same id when the game is started from the command-line
local cind = 'UI-' .. unit.id .. unit.max_hitpoints .. unit.side
-- If cache for this unit exists, return it
if cache and cache[cind] then
return cache[cind]
end
-- Otherwise collect the information
local unit_info = {
attacks = {},
resist_mod = {},
alignment = unit.alignment
}
local attacks = unit.attacks
for i_a = 1,#attacks do
local attack = attacks[i_a]
-- Extract information for specials; we do this first because some
-- custom special might have the same name as one of the default scalar fields
local a = {}
for _,sp in ipairs(attack.specials) do
if (sp[1] == 'damage') then -- this is 'backstab'
if (sp[2].id == 'backstab') then
a.backstab = true
else
if (sp[2].id == 'charge') then a.charge = true end
end
else
-- magical, marksman
if (sp[1] == 'chance_to_hit') then
a[sp[2].id or 'no_id'] = true
else
a[sp[1]] = true
end
end
end
-- Now extract the scalar (string and number) values from attack
a.damage = attack.damage
a.type = attack.type
a.range = attack.range
-- number must be defined for battle_calcs.best_weapons()
a.number = attack.number or 0
table.insert(unit_info.attacks, a)
end
local attack_types = { "arcane", "blade", "cold", "fire", "impact", "pierce" }
for _,attack_type in ipairs(attack_types) do
unit_info.resist_mod[attack_type] = 1 - unit:resistance_against(attack_type) / 100.
end
if cache then cache[cind] = unit_info end
return unit_info
end
function battle_calcs.strike_damage(attacker, defender, att_weapon, def_weapon, dst, cache)
-- Return the single strike damage of an attack by @attacker on @defender
-- Also returns the other information about the attack (since we're accessing the information already anyway)
-- Here, @att_weapon and @def_weapon are the weapon numbers in Lua counts, i.e., counts start at 1
-- If @def_weapon = 0, return 0 for defender damage
-- This can be used for defenders that do not have the right kind of weapon, or if
-- only the attacker damage is of interest
-- @dst: attack location, to take terrain time of day, illumination etc. into account
-- For the defender, the current location is assumed
--
-- 'cache' can be given to cache strike damage and to pass through to battle_calcs.unit_attack_info()
-- Set up a cache index. We use id+max_hitpoints+side for each unit, since the
-- unit can level up.
-- Also need to add the weapons and lawful_bonus values for each unit
local att_lawful_bonus = wesnoth.get_time_of_day({ dst[1], dst[2], true }).lawful_bonus
local def_lawful_bonus = wesnoth.get_time_of_day({ defender.x, defender.y, true }).lawful_bonus
local cind = 'SD-' .. attacker.id .. attacker.max_hitpoints .. attacker.side
cind = cind .. 'x' .. defender.id .. defender.max_hitpoints .. defender.side
cind = cind .. '-' .. att_weapon .. 'x' .. def_weapon
cind = cind .. '-' .. att_lawful_bonus .. 'x' .. def_lawful_bonus
-- If cache for this unit exists, return it
if cache and cache[cind] then
return cache[cind].att_damage, cache[cind].def_damage, cache[cind].att_attack, cache[cind].def_attack
end
local attacker_info = battle_calcs.unit_attack_info(attacker, cache)
local defender_info = battle_calcs.unit_attack_info(defender, cache)
-- Attacker base damage
local att_damage = attacker_info.attacks[att_weapon].damage
-- Opponent resistance modifier
local att_multiplier = defender_info.resist_mod[attacker_info.attacks[att_weapon].type] or 1
-- TOD modifier
att_multiplier = att_multiplier * AH.get_unit_time_of_day_bonus(attacker_info.alignment, att_lawful_bonus)
-- Now do all this for the defender, if def_weapon ~= 0
local def_damage, def_multiplier = 0, 1.
if (def_weapon ~= 0) then
-- Defender base damage
def_damage = defender_info.attacks[def_weapon].damage
-- Opponent resistance modifier
def_multiplier = attacker_info.resist_mod[defender_info.attacks[def_weapon].type] or 1
-- TOD modifier
def_multiplier = def_multiplier * AH.get_unit_time_of_day_bonus(defender_info.alignment, def_lawful_bonus)
end
-- Take 'charge' into account
if attacker_info.attacks[att_weapon].charge then
att_damage = att_damage * 2
def_damage = def_damage * 2
end
-- Rounding of .5 values is done differently depending on whether the
-- multiplier is greater or smaller than 1
if (att_multiplier > 1) then
att_damage = H.round(att_damage * att_multiplier - 0.001)
else
att_damage = H.round(att_damage * att_multiplier + 0.001)
end
if (def_weapon ~= 0) then
if (def_multiplier > 1) then
def_damage = H.round(def_damage * def_multiplier - 0.001)
else
def_damage = H.round(def_damage * def_multiplier + 0.001)
end
end
if cache then
cache[cind] = {
att_damage = att_damage,
def_damage = def_damage,
att_attack = attacker_info.attacks[att_weapon],
def_attack = defender_info.attacks[def_weapon]
}
end
return att_damage, def_damage, attacker_info.attacks[att_weapon], defender_info.attacks[def_weapon]
end
function battle_calcs.best_weapons(attacker, defender, dst, cache)
-- Return the number (index) of the best weapons for @attacker and @defender
-- @dst: attack location, to take terrain time of day, illumination etc. into account
-- For the defender, the current location is assumed
-- Ideally, we would do a full attack_rating here for all combinations,
-- but that would take too long. So we simply define the best weapons
-- as those that has the biggest difference between
-- damage done and damage received (the latter divided by 2)
-- Returns 0 if defender does not have a weapon for this range
--
-- 'cache' can be given to cache best weapons
-- Set up a cache index. We use id+max_hitpoints+side for each unit, since the
-- unit can level up.
-- Also need to add the weapons and lawful_bonus values for each unit
local att_lawful_bonus = wesnoth.get_time_of_day({ dst[1], dst[2], true }).lawful_bonus
local def_lawful_bonus = wesnoth.get_time_of_day({ defender.x, defender.y, true }).lawful_bonus
local cind = 'BW-' .. attacker.id .. attacker.max_hitpoints .. attacker.side
cind = cind .. 'x' .. defender.id .. defender.max_hitpoints .. defender.side
cind = cind .. '-' .. att_lawful_bonus .. 'x' .. def_lawful_bonus
-- If cache for this unit exists, return it
if cache and cache[cind] then
return cache[cind].best_att_weapon, cache[cind].best_def_weapon
end
local attacker_info = battle_calcs.unit_attack_info(attacker, cache)
local defender_info = battle_calcs.unit_attack_info(defender, cache)
-- Best attacker weapon
local max_rating, best_att_weapon, best_def_weapon = - math.huge, 0, 0
for att_weapon_number,att_weapon in ipairs(attacker_info.attacks) do
local att_damage = battle_calcs.strike_damage(attacker, defender, att_weapon_number, 0, { dst[1], dst[2] }, cache)
local max_def_rating, tmp_best_def_weapon = - math.huge, 0
for def_weapon_number,def_weapon in ipairs(defender_info.attacks) do
if (def_weapon.range == att_weapon.range) then
local def_damage = battle_calcs.strike_damage(defender, attacker, def_weapon_number, 0, { defender.x, defender.y }, cache)
local def_rating = def_damage * def_weapon.number
if (def_rating > max_def_rating) then
max_def_rating, tmp_best_def_weapon = def_rating, def_weapon_number
end
end
end
local rating = att_damage * att_weapon.number
if (max_def_rating > - math.huge) then rating = rating - max_def_rating / 2. end
if (rating > max_rating) then
max_rating, best_att_weapon, best_def_weapon = rating, att_weapon_number, tmp_best_def_weapon
end
end
if cache then
cache[cind] = { best_att_weapon = best_att_weapon, best_def_weapon = best_def_weapon }
end
return best_att_weapon, best_def_weapon
end
function battle_calcs.add_next_strike(cfg, arr, n_att, n_def, att_strike, hit_miss_counts, hit_miss_str)
-- Recursive function that sets up the sequences of strikes (misses and hits)
-- Each call corresponds to one strike of one of the combattants and can be
-- either miss (value 0) or hit (1)
--
-- Inputs:
-- - @cfg: config table with sub-tables att/def for the attacker/defender with the following fields:
-- - strikes: total number of strikes
-- - max_hits: maximum number of hits the unit can survive
-- - firststrike: set to true if attack has firststrike special
-- - @arr: an empty array that will hold the output table
-- - Other parameters of for recursion purposes only and are initialized below
-- On the first call of this function, initialize variables
-- Counts for hits/misses by both units:
-- - Indices 1 & 2: hit/miss for attacker
-- - Indices 3 & 4: hit/miss for defender
hit_miss_counts = hit_miss_counts or { 0, 0, 0, 0 }
hit_miss_str = hit_miss_str or '' -- string with the hit/miss sequence; for visualization only
-- Strike counts
-- - n_att/n_def = number of strikes taken by attacker/defender
-- - att_strike: if true, it's the attacker's turn, otherwise it's the defender's turn
if (not n_att) then
if cfg.def.firststrike and (not cfg.att.firststrike) then
n_att = 0
n_def = 1
att_strike = false
else
n_att = 1
n_def = 0
att_strike = true
end
else
if att_strike then
if (n_def < cfg.def.strikes) then
n_def = n_def + 1
att_strike = false
else
n_att = n_att + 1
end
else
if (n_att < cfg.att.strikes) then
n_att = n_att + 1
att_strike = true
else
n_def = n_def + 1
end
end
end
-- Create both a hit and a miss
for i = 0,1 do -- 0:miss, 1: hit
-- hit/miss counts and string for this call
local tmp_hmc = AH.table_copy(hit_miss_counts)
local tmp_hmstr = ''
-- Flag whether the opponent was killed by this strike
local killed_opp = false -- Defaults to falso
if att_strike then
tmp_hmstr = hit_miss_str .. i -- attacker hit/miss in string: 0 or 1
tmp_hmc[i+1] = tmp_hmc[i+1] + 1 -- Increment hit/miss counts
-- Set variable if opponent was killed:
if (tmp_hmc[2] > cfg.def.max_hits) then killed_opp = true end
-- Even values of n are strikes by the defender
else
tmp_hmstr = hit_miss_str .. (i+2) -- defender hit/miss in string: 2 or 3
tmp_hmc[i+3] = tmp_hmc[i+3] + 1 -- Increment hit/miss counts
-- Set variable if opponent was killed:
if (tmp_hmc[4] > cfg.att.max_hits) then killed_opp = true end
end
-- If we've reached the total number of strikes, add this hit/miss combination to table,
-- but only if the opponent wasn't killed, as that would end the battle
if (n_att + n_def < cfg.att.strikes + cfg.def.strikes) and (not killed_opp) then
battle_calcs.add_next_strike(cfg, arr, n_att, n_def, att_strike, tmp_hmc, tmp_hmstr)
-- Otherwise, call the next recursion level
else
table.insert(arr, { hit_miss_str = tmp_hmstr, hit_miss_counts = tmp_hmc })
end
end
end
function battle_calcs.battle_outcome_coefficients(cfg, cache)
-- Determine the coefficients needed to calculate the hitpoint probability distribution
-- of a given battle
-- Inputs:
-- - @cfg: config table with sub-tables att/def for the attacker/defender with the following fields:
-- - strikes: total number of strikes
-- - max_hits: maximum number of hits the unit can survive
-- - firststrike: whether the unit has firststrike weapon special on this attack
-- The result can be cached if variable 'cache' is given
--
-- Output: table with the coefficients needed to calculate the distribution for both attacker and defender
-- First index: number of hits landed on the defender. Each of those contains an array of
-- coefficient tables, of format:
-- { num = value, am = value, ah = value, dm = value, dh = value }
-- This gives one term in a sum of form:
-- num * ahp^ah * (1-ahp)^am * dhp^dh * (1-dhp)^dm
-- where ahp is the probability that the attacker will land a hit
-- and dhp is the same for the defender
-- Terms that have exponents of 0 are omitted
-- Set up the cache id
local cind = 'coeff-' .. cfg.att.strikes .. '-' .. cfg.att.max_hits
if cfg.att.firststrike then cind = cind .. 'fs' end
cind = cind .. 'x' .. cfg.def.strikes .. '-' .. cfg.def.max_hits
if cfg.def.firststrike then cind = cind .. 'fs' end
-- If cache for this unit exists, return it
if cache and cache[cind] then
return cache[cind].coeffs_att, cache[cind].coeffs_def
end
-- Get the hit/miss counts for the battle
local hit_miss_counts = {}
battle_calcs.add_next_strike(cfg, hit_miss_counts)
-- We first calculate the coefficients for the defender HP distribution
-- so this is sorted by the number of hits the attacker lands
-- 'counts' is an array 4 layers deep, where the indices are the number of misses/hits
-- are the indices in order attacker miss, attacker hit, defender miss, defender hit
-- This is so that they can be grouped by number of attacker hits/misses, for
-- subsequent simplification
-- The element value is number of times we get the given combination of hits/misses
local counts = {}
for _,count in ipairs(hit_miss_counts) do
local i1 = count.hit_miss_counts[1]
local i2 = count.hit_miss_counts[2]
local i3 = count.hit_miss_counts[3]
local i4 = count.hit_miss_counts[4]
if not counts[i1] then counts[i1] = {} end
if not counts[i1][i2] then counts[i1][i2] = {} end
if not counts[i1][i2][i3] then counts[i1][i2][i3] = {} end
counts[i1][i2][i3][i4] = (counts[i1][i2][i3][i4] or 0) + 1
end
local coeffs_def = {}
for am,v1 in pairs(counts) do -- attacker miss count
for ah,v2 in pairs(v1) do -- attacker hit count
-- Set up the exponent coefficients for attacker hits/misses
local exp = {} -- Array for an individual set of coefficients
-- Only populate those indices that have exponents > 0
if (am > 0) then exp.am = am end
if (ah > 0) then exp.ah = ah end
-- We combine results by testing whether they produce the same sum
-- with two very different hit probabilities, hp1 = 0.6, hp2 = 0.137
-- This will only happen is the coefficients add up to multiples of 1
local sum1, sum2 = 0,0
local hp1, hp2 = 0.6, 0.137
for dm,v3 in pairs(v2) do -- defender miss count
for dh,num in pairs(v3) do -- defender hit count
sum1 = sum1 + num * hp1^dh * (1 - hp1)^dm
sum2 = sum2 + num * hp2^dh * (1 - hp2)^dm
end
end
-- Now, coefficients are set up for each value of total hits by attacker
-- This holds all the coefficients that need to be added to get the propability
-- of the defender receiving this number of hits
if (not coeffs_def[ah]) then coeffs_def[ah] = {} end
-- If sum1 and sum2 are equal, that means all the defender probs added up to 1, or
-- multiple thereof, which means the can all be combine in the calculation
if (math.abs(sum1 - sum2) < 1e-9) then
exp.num = sum1
table.insert(coeffs_def[ah], exp)
-- If not, the defender probs don't add up to something nice and all
-- need to be calculated one by one
else
for dm,v3 in pairs(v2) do -- defender miss count
for dh,num in pairs(v3) do -- defender hit count
local tmp_exp = AH.table_copy(exp)
tmp_exp.num = num
if (dm > 0) then tmp_exp.dm = dm end
if (dh > 0) then tmp_exp.dh = dh end
table.insert(coeffs_def[ah], tmp_exp)
end
end
end
end
end
-- Now we do the same for the HP distribution of the attacker,
-- which means everything needs to be sorted by defender hits
local counts = {}
for _,count in ipairs(hit_miss_counts) do
local i1 = count.hit_miss_counts[3] -- note that the order here is different from above
local i2 = count.hit_miss_counts[4]
local i3 = count.hit_miss_counts[1]
local i4 = count.hit_miss_counts[2]
if not counts[i1] then counts[i1] = {} end
if not counts[i1][i2] then counts[i1][i2] = {} end
if not counts[i1][i2][i3] then counts[i1][i2][i3] = {} end
counts[i1][i2][i3][i4] = (counts[i1][i2][i3][i4] or 0) + 1
end
local coeffs_att = {}
for dm,v1 in pairs(counts) do -- defender miss count
for dh,v2 in pairs(v1) do -- defender hit count
-- Set up the exponent coefficients for attacker hits/misses
local exp = {} -- Array for an individual set of coefficients
-- Only populate those indices that have exponents > 0
if (dm > 0) then exp.dm = dm end
if (dh > 0) then exp.dh = dh end
-- We combine results by testing whether they produce the same sum
-- with two very different hit probabilities, hp1 = 0.6, hp2 = 0.137
-- This will only happen is the coefficients add up to multiples of 1
local sum1, sum2 = 0,0
local hp1, hp2 = 0.6, 0.137
for am,v3 in pairs(v2) do -- attacker miss count
for ah,num in pairs(v3) do -- attacker hit count
sum1 = sum1 + num * hp1^ah * (1 - hp1)^am
sum2 = sum2 + num * hp2^ah * (1 - hp2)^am
end
end
-- Now, coefficients are set up for each value of total hits by attacker
-- This holds all the coefficients that need to be added to get the propability
-- of the defender receiving this number of hits
if (not coeffs_att[dh]) then coeffs_att[dh] = {} end
-- If sum1 and sum2 are equal, that means all the defender probs added up to 1, or
-- multiple thereof, which means the can all be combine in the calculation
if (math.abs(sum1 - sum2) < 1e-9) then
exp.num = sum1
table.insert(coeffs_att[dh], exp)
-- If not, the defender probs don't add up to something nice and all
-- need to be calculated one by one
else
for am,v3 in pairs(v2) do -- defender miss count
for ah,num in pairs(v3) do -- defender hit count
local tmp_exp = AH.table_copy(exp)
tmp_exp.num = num
if (am > 0) then tmp_exp.am = am end
if (ah > 0) then tmp_exp.ah = ah end
table.insert(coeffs_att[dh], tmp_exp)
end
end
end
end
end
-- The probability for the number of hits with the most terms can be skipped
-- and 1-sum(other_terms) can be used instead. Set a flag for which term to skip
local max_number, biggest_equation = 0, -1
for hits,v in pairs(coeffs_att) do
local number = 0
for _,c in pairs(v) do number = number + 1 end
if (number > max_number) then
max_number, biggest_equation = number, hits
end
end
coeffs_att[biggest_equation].skip = true
local max_number, biggest_equation = 0, -1
for hits,v in pairs(coeffs_def) do
local number = 0
for _,c in pairs(v) do number = number + 1 end
if (number > max_number) then
max_number, biggest_equation = number, hits
end
end
coeffs_def[biggest_equation].skip = true
if cache then cache[cind] = { coeffs_att = coeffs_att, coeffs_def = coeffs_def } end
return coeffs_att, coeffs_def
end
function battle_calcs.print_coefficients()
-- Print out the set of coefficients for a given number of attacker and defender strikes
-- Also print numerical values for a given hit probability
-- This function is for debugging purposes only
-- Configure these values at will
local attacker_strikes, defender_strikes = 3, 3 -- number of strikes
local att_hit_prob, def_hit_prob = 0.8, 0.4 -- probability of landing a hit attacker/defender
local attacker_coeffs = true -- attacker coefficients if set to true, defender coefficients otherwise
local defender_firststrike, attacker_firststrike = true, false
-- Go through all combinations of maximum hits either attacker or defender can survive
-- Note how this has to be crossed between ahits and defender_strikes and vice versa
for ahits = defender_strikes,0,-1 do
for dhits = attacker_strikes,0,-1 do
-- Get the coefficients for this case
local cfg = {
att = { strikes = attacker_strikes, max_hits = ahits, firststrike = attacker_firststrike },
def = { strikes = defender_strikes, max_hits = dhits, firststrike = defender_firststrike }
}
local coeffs, dummy = {}, {}
if attacker_coeffs then
coeffs = battle_calcs.battle_outcome_coefficients(cfg)
else
dummy, coeffs = battle_calcs.battle_outcome_coefficients(cfg)
end
std_print()
std_print('Attacker: ' .. cfg.att.strikes .. ' strikes, can survive ' .. cfg.att.max_hits .. ' hits')
std_print('Defender: ' .. cfg.def.strikes .. ' strikes, can survive ' .. cfg.def.max_hits .. ' hits')
std_print('Chance of hits on defender: ')
-- The first indices of coeffs are the possible number of hits the attacker can land on the defender
for hits = 0,#coeffs do
local hit_prob = 0. -- probability for this number of hits
local str = '' -- output string
local combs = coeffs[hits] -- the combinations of coefficients to be evaluated
for i,exp in ipairs(combs) do -- exp: exponents (and factor) for a set
local prob = exp.num -- probability for this set
str = str .. exp.num
if exp.am then
prob = prob * (1 - att_hit_prob) ^ exp.am
str = str .. ' pma^' .. exp.am
end
if exp.ah then
prob = prob * att_hit_prob ^ exp.ah
str = str .. ' pha^' .. exp.ah
end
if exp.dm then
prob = prob * (1 - def_hit_prob) ^ exp.dm
str = str .. ' pmd^' .. exp.dm
end
if exp.dh then
prob = prob * def_hit_prob ^ exp.dh
str = str .. ' phd^' .. exp.dh
end
hit_prob = hit_prob + prob -- total probabilty for this number of hits landed
if (i ~= #combs) then str = str .. ' + ' end
end
local skip_str = ''
if combs.skip then skip_str = ' (skip)' end
std_print(hits .. skip_str .. ': ' .. str)
std_print(' = ' .. hit_prob)
end
end
end
end
function battle_calcs.hp_distribution(coeffs, att_hit_prob, def_hit_prob, starting_hp, damage, opp_attack)
-- Multiply out the coefficients from battle_calcs.battle_outcome_coefficients()
-- For a given attacker and defender hit/miss probability
-- Also needed: the starting HP for the unit and the damage done by the opponent
-- and the opponent attack information @opp_attack
local stats = { hp_chance = {}, average_hp = 0 }
local skip_hp, skip_prob = -1, 1
for hits = 0,#coeffs do
local hp = starting_hp - hits * damage
if (hp < 0) then hp = 0 end
-- Calculation of the outcome with the most terms can be skipped
if coeffs[hits].skip then
skip_hp = hp
else
local hp_prob = 0. -- probability for this number of hits
for _,exp in ipairs(coeffs[hits]) do -- exp: exponents (and factor) for a set
local prob = exp.num -- probability for this set
if exp.am then prob = prob * (1 - att_hit_prob) ^ exp.am end
if exp.ah then prob = prob * att_hit_prob ^ exp.ah end
if exp.dm then prob = prob * (1 - def_hit_prob) ^ exp.dm end
if exp.dh then prob = prob * def_hit_prob ^ exp.dh end
hp_prob = hp_prob + prob -- total probabilty for this number of hits landed
end
stats.hp_chance[hp] = hp_prob
stats.average_hp = stats.average_hp + hp * hp_prob
-- Also subtract this probability from the total prob. (=1), to get prob. of skipped outcome
skip_prob = skip_prob - hp_prob
end
end
-- Add in the outcome that was skipped
stats.hp_chance[skip_hp] = skip_prob
stats.average_hp = stats.average_hp + skip_hp * skip_prob
-- And always set hp_chance[0] since it is of such importance in the analysis
stats.hp_chance[0] = stats.hp_chance[0] or 0
-- Add poison probability
if opp_attack and opp_attack.poison then
stats.poisoned = 1. - stats.hp_chance[starting_hp]
else
stats.poisoned = 0
end
-- Add slow probability
if opp_attack and opp_attack.slow then
stats.slowed = 1. - stats.hp_chance[starting_hp]
else
stats.slowed = 0
end
return stats
end
function battle_calcs.battle_outcome(attacker, defender, cfg, cache)
-- Calculate the stats of a combat by @attacker vs. @defender
-- @cfg: optional input parameters
-- - att_weapon/def_weapon: attacker/defender weapon number
-- if not given, get "best" weapon (Note: both must be given, or they will both be determined)
-- - dst: { x, y }: the attack location; defaults to { attacker.x, attacker.y }
-- @cache: to be passed on to other functions. battle_outcome itself is not cached, too many factors enter
cfg = cfg or {}
local dst = cfg.dst or { attacker.x, attacker.y }
local att_weapon, def_weapon = 0, 0
if (not cfg.att_weapon) or (not cfg.def_weapon) then
att_weapon, def_weapon = battle_calcs.best_weapons(attacker, defender, dst, cache)
else
att_weapon, def_weapon = cfg.att_weapon, cfg.def_weapon
end
-- Collect all the information needed for the calculation
-- Strike damage and numbers
local att_damage, def_damage, att_attack, def_attack =
battle_calcs.strike_damage(attacker, defender, att_weapon, def_weapon, { dst[1], dst[2] }, cache)
-- Take swarm into account
local att_strikes, def_strikes = att_attack.number, 0
if (def_damage > 0) then
def_strikes = def_attack.number
end
if att_attack.swarm then
att_strikes = math.floor(att_strikes * attacker.hitpoints / attacker.max_hitpoints)
end
if def_attack and def_attack.swarm then
def_strikes = math.floor(def_strikes * defender.hitpoints / defender.max_hitpoints)
end
-- Maximum number of hits that either unit can survive
local att_max_hits = math.floor((attacker.hitpoints - 1) / def_damage)
if (att_max_hits > def_strikes) then att_max_hits = def_strikes end
local def_max_hits = math.floor((defender.hitpoints - 1) / att_damage)
if (def_max_hits > att_strikes) then def_max_hits = att_strikes end
-- Probability of landing a hit
local map = wesnoth.current.map
local att_hit_prob = 1 - defender:defense_on(map[defender]) / 100.
local def_hit_prob = 1 - attacker:defense_on(map[dst]) / 100.
-- Magical: attack and defense, and under all circumstances
if att_attack.magical then att_hit_prob = 0.7 end
if def_attack and def_attack.magical then def_hit_prob = 0.7 end
-- Marksman: attack only, and only if terrain defense is less
if att_attack.marksman and (att_hit_prob < 0.6) then
att_hit_prob = 0.6
end
-- Get the coefficients for this kind of combat
local def_firstrike = false
if def_attack and def_attack.firststrike then def_firstrike = true end
local cfg = {
att = { strikes = att_strikes, max_hits = att_max_hits, firststrike = att_attack.firststrike },
def = { strikes = def_strikes, max_hits = def_max_hits, firststrike = def_firstrike }
}
local att_coeffs, def_coeffs = battle_calcs.battle_outcome_coefficients(cfg, cache)
-- And multiply out the factors
-- Note that att_hit_prob, def_hit_prob need to be in that order for both calls
local att_stats = battle_calcs.hp_distribution(att_coeffs, att_hit_prob, def_hit_prob, attacker.hitpoints, def_damage, def_attack)
local def_stats = battle_calcs.hp_distribution(def_coeffs, att_hit_prob, def_hit_prob, defender.hitpoints, att_damage, att_attack)
return att_stats, def_stats
end
function battle_calcs.simulate_combat_fake()
-- A function to return a fake simulate_combat result
-- Debug function to test how long simulate_combat takes
-- It doesn't need any arguments -> can be called with the arguments of other simulate_combat functions
local att_stats, def_stats = { hp_chance = {} }, { hp_chance = {} }
att_stats.hp_chance[0] = 0
att_stats.hp_chance[21], att_stats.hp_chance[23], att_stats.hp_chance[25], att_stats.hp_chance[27] = 0.125, 0.375, 0.375, 0.125
att_stats.poisoned, att_stats.slowed, att_stats.average_hp = 0.875, 0, 24
def_stats.hp_chance[0], def_stats.hp_chance[2], def_stats.hp_chance[10] = 0.09, 0.42, 0.49
def_stats.poisoned, def_stats.slowed, def_stats.average_hp = 0, 0, 1.74
return att_stats, def_stats
end
function battle_calcs.simulate_combat_loc(attacker, dst, defender, weapon)
-- Get simulate_combat results for unit @attacker attacking unit @defender
-- when on terrain of same type as that at @dst, which is of form { x, y }
-- If @weapon is set, use that weapon (Lua index starting at 1), otherwise use best weapon
local attacker_dst = attacker:clone()
attacker_dst.x, attacker_dst.y = dst[1], dst[2]
if weapon then
return wesnoth.simulate_combat(attacker_dst, weapon, defender)
else
return wesnoth.simulate_combat(attacker_dst, defender)
end
end
function battle_calcs.attack_rating(attacker, defender, dst, cfg, cache)
-- Returns a common (but configurable) rating for attacks
-- Inputs:
-- @attacker: attacker unit
-- @defender: defender unit
-- @dst: the attack location in form { x, y }
-- @cfg: table of optional inputs and configurable rating parameters
-- Optional inputs:
-- - att_stats, def_stats: if given, use these stats, otherwise calculate them here
-- Note: these are calculated in combination, that is they either both need to be passed or both be omitted
-- - att_weapon/def_weapon: the attacker/defender weapon to be used if calculating battle stats here
-- This parameter is meaningless (unused) if att_stats/def_stats are passed
-- Defaults to weapon that does most damage to the opponent
-- Note: as with the stats, they either both need to be passed or both be omitted
-- @cache: cache table to be passed to battle_calcs.battle_outcome
--
-- Returns:
-- - Overall rating for the attack or attack combo
-- - Defender rating: not additive for attack combos; needs to be calculated for the
-- defender stats of the last attack in a combo (that works for everything except
-- the rating whether the defender is about to level in the attack combo)
-- - Attacker rating: this one is split up into two terms:
-- - a term that is additive for individual attacks in a combo
-- - a term that needs to be average for the individual attacks in a combo
-- - att_stats, def_stats: useful if they were calculated here, rather than passed down
cfg = cfg or {}
-- Set up the config parameters for the rating
local enemy_leader_weight = cfg.enemy_leader_weight or 5.
local defender_starting_damage_weight = cfg.defender_starting_damage_weight or 0.33
local xp_weight = cfg.xp_weight or 0.25
local level_weight = cfg.level_weight or 1.0
local defender_level_weight = cfg.defender_level_weight or 1.0
local distance_leader_weight = cfg.distance_leader_weight or 0.002
local defense_weight = cfg.defense_weight or 0.1
local occupied_hex_penalty = cfg.occupied_hex_penalty or -0.001
local own_value_weight = cfg.own_value_weight or 1.0
-- Get att_stats, def_stats
-- If they are passed in cfg, use those
local att_stats, def_stats = {}, {}
if (not cfg.att_stats) or (not cfg.def_stats) then
-- If cfg specifies the weapons use those, otherwise use "best" weapons
-- In the latter case, cfg.???_weapon will be nil, which will be passed on
local battle_cfg = { att_weapon = cfg.att_weapon, def_weapon = cfg.def_weapon, dst = dst }
att_stats,def_stats = battle_calcs.battle_outcome(attacker, defender, battle_cfg, cache)
else
att_stats, def_stats = cfg.att_stats, cfg.def_stats
end
-- We also need the leader (well, the location at least)
-- because if there's no other difference, prefer location _between_ the leader and the defender
local leader = wesnoth.units.find_on_map { side = attacker.side, canrecruit = 'yes' }[1]
------ All the attacker contributions: ------
-- Add up rating for the attacking unit
-- We add this up in units of fraction of max_hitpoints
-- It is multiplied by unit cost later, to get a gold equivalent value
-- Average damage to unit is negative rating
local damage = attacker.hitpoints - att_stats.average_hp
-- Count poisoned as additional damage done by poison times probability of being poisoned
if (att_stats.poisoned ~= 0) then
damage = damage + wesnoth.game_config.poison_amount * (att_stats.poisoned - att_stats.hp_chance[0])
end
-- Count slowed as additional 6 HP damage times probability of being slowed
if (att_stats.slowed ~= 0) then
damage = damage + 6 * (att_stats.slowed - att_stats.hp_chance[0])
end
local map = wesnoth.map.get
-- If attack is from a healing location, count that as slightly more than the healing amount
damage = damage - 1.25 * wesnoth.get_terrain_info(map[dst]).healing
-- Equivalently, if attack is adjacent to an unoccupied healing location, that's bad
for xa,ya in H.adjacent_tiles(dst[1], dst[2]) do
local healing = wesnoth.get_terrain_info(map[{xa, ya}]).healing
if (healing > 0) and (not wesnoth.units.get(xa, ya)) then
damage = damage + 1.25 * healing
end
end
if (damage < 0) then damage = 0 end
-- Fraction damage (= fractional value of the unit)
local value_fraction = - damage / attacker.max_hitpoints
-- Additional, subtract the chance to die, in order to (de)emphasize units that might die
value_fraction = value_fraction - att_stats.hp_chance[0]
-- In addition, potentially leveling up in this attack is a huge bonus,
-- proportional to the chance of it happening and the chance of not dying itself
local level_bonus = 0.
local defender_level = defender.level
if (attacker.max_experience - attacker.experience <= defender_level * wesnoth.game_config.combat_experience) then
level_bonus = 1. - att_stats.hp_chance[0]
else
if (defender_level == 0) then defender_level = 0.5 end
if (attacker.max_experience - attacker.experience <= defender_level * wesnoth.game_config.kill_experience) then
level_bonus = (1. - att_stats.hp_chance[0]) * def_stats.hp_chance[0]
end
end
value_fraction = value_fraction + level_bonus * level_weight
-- Now convert this into gold-equivalent value
local attacker_value = attacker.cost
-- Being closer to leveling is good (this makes AI prefer units with lots of XP)
local xp_bonus = attacker.experience / attacker.max_experience
attacker_value = attacker_value * (1. + xp_bonus * xp_weight)
local attacker_rating = value_fraction * attacker_value
------ Now (most of) the same for the defender ------
-- Average damage to defender is positive rating
local damage = defender.hitpoints - def_stats.average_hp
-- Count poisoned as additional damage done by poison times probability of being poisoned
if (def_stats.poisoned ~= 0) then
damage = damage + wesnoth.game_config.poison_amount * (def_stats.poisoned - def_stats.hp_chance[0])
end
-- Count slowed as additional 6 HP damage times probability of being slowed
if (def_stats.slowed ~= 0) then
damage = damage + 6 * (def_stats.slowed - def_stats.hp_chance[0])
end
-- If defender is on a healing location, count that as slightly more than the healing amount
damage = damage - 1.25 * wesnoth.get_terrain_info(map[defender]).healing
if (damage < 0) then damage = 0. end
-- Fraction damage (= fractional value of the unit)
local value_fraction = damage / defender.max_hitpoints
-- Additional, add the chance to kill, in order to emphasize enemies we might be able to kill
value_fraction = value_fraction + def_stats.hp_chance[0]
-- In addition, the defender potentially leveling up in this attack is a huge penalty,
-- proportional to the chance of it happening and the chance of not dying itself
local defender_level_penalty = 0.
local attacker_level = attacker.level
if (defender.max_experience - defender.experience <= attacker_level * wesnoth.game_config.combat_experience) then
defender_level_penalty = 1. - def_stats.hp_chance[0]
else
if (attacker_level == 0) then attacker_level = 0.5 end
if (defender.max_experience - defender.experience <= attacker_level * wesnoth.game_config.kill_experience) then
defender_level_penalty = (1. - def_stats.hp_chance[0]) * att_stats.hp_chance[0]
end
end
value_fraction = value_fraction - defender_level_penalty * defender_level_weight
-- Now convert this into gold-equivalent value
local defender_value = defender.cost
-- If this is the enemy leader, make damage to it much more important
if defender.canrecruit then
defender_value = defender_value * enemy_leader_weight
end
-- And prefer to attack already damaged enemies
local defender_starting_damage_fraction = (defender.max_hitpoints - defender.hitpoints) / defender.max_hitpoints
defender_value = defender_value * (1. + defender_starting_damage_fraction * defender_starting_damage_weight)
-- Being closer to leveling is good, we want to get rid of those enemies first
local xp_bonus = defender.experience / defender.max_experience
defender_value = defender_value * (1. + xp_bonus * xp_weight)
-- If defender is on a village, add a bonus rating (we want to get rid of those preferentially)
-- So yes, this is positive, even though it's a plus for the defender
-- Note: defenders on healing locations also got a negative damage rating above (these don't exactly cancel each other though)
if wesnoth.get_terrain_info(map[defender]).village then
defender_value = defender_value * (1. + 10. / attacker.max_hitpoints)
end
-- We also add a few contributions that are not directly attack/damage dependent
-- These are added to the defender rating for two reasons:
-- 1. Defender rating is positive (and thus contributions can be made positive)
-- 2. It is then independent of value of aggression (cfg.own_value_weight)
--
-- These are kept small though, so they mostly only serve as tie breakers
-- And yes, they might bring the overall rating from slightly negative to slightly positive
-- or vice versa, but as that is only approximate anyway, we keep it this way for simplicity
-- We don't need a bonus for good terrain for the attacker, as that is covered in the damage calculation
-- However, we add a small bonus for good terrain defense of the _defender_ on the _attack_ hex
-- This is in order to take good terrain away from defender on next move, all else being equal
local defender_defense = - (100 - defender:defense_on(map[dst])) / 100.
defender_value = defender_value + defender_defense * defense_weight
-- Get a very small bonus for hexes in between defender and AI leader
-- 'relative_distances' is larger for attack hexes closer to the side leader (possible values: -1 .. 1)
if leader then
local relative_distances =
M.distance_between(defender.x, defender.y, leader.x, leader.y)
- M.distance_between(dst[1], dst[2], leader.x, leader.y)
defender_value = defender_value + relative_distances * distance_leader_weight
end
-- Add a very small penalty for attack hexes occupied by other units
-- Note: it must be checked previously that the unit on the hex can move away
if (dst[1] ~= attacker.x) or (dst[2] ~= attacker.y) then
if wesnoth.units.get(dst[1], dst[2]) then
defender_value = defender_value + occupied_hex_penalty
end
end
local defender_rating = value_fraction * defender_value
-- Finally apply factor of own unit weight to defender unit weight
attacker_rating = attacker_rating * own_value_weight
local rating = defender_rating + attacker_rating
return rating, defender_rating, attacker_rating, att_stats, def_stats
end
function battle_calcs.attack_combo_stats(tmp_attackers, tmp_dsts, defender, cache, cache_this_move)
-- Calculate attack combination outcomes using
-- @tmp_attackers: array of attacker units (this is done so that
-- the units need not be found here, as likely doing it in the
-- calling function is more efficient (because of repetition)
-- @tmp_dsts: array of the hexes (format { x, y }) from which the attackers attack
-- must be in same order as @attackers
-- @defender: the unit being attacked
-- @cache: the cache table to be passed through to other battle_calcs functions
-- attack_combo_stats itself is not cached, except for in cache_this_move below
-- @cache_this_move: an optional table of pre-calculated attack outcomes
-- - This is different from the other cache tables used in this file
-- - This table may only persist for this move (move, not turn !!!), as otherwise too many things change
--
-- Return values:
-- - The rating for this attack combination calculated from battle_calcs.attack_rating() results
-- - The sorted attackers and dsts arrays
-- - att_stats: an array of stats for each attacker, in the same order as 'attackers'
-- - defender combo stats: one set of stats containing the defender stats after the attack combination
-- - def_stats: an array of defender stats for each individual attack, in the same order as 'attackers'
cache_this_move = cache_this_move or {}
-- We first simulate and rate the individual attacks
local ratings, tmp_attacker_ratings = {}, {}
local tmp_att_stats, tmp_def_stats = {}, {}
local defender_ind = defender.x * 1000 + defender.y
for i,attacker in ipairs(tmp_attackers) do
-- Initialize or use the 'cache_this_move' table
local att_ind = attacker.x * 1000 + attacker.y
local dst_ind = tmp_dsts[i][1] * 1000 + tmp_dsts[i][2]
if (not cache_this_move[defender_ind]) then cache_this_move[defender_ind] = {} end
if (not cache_this_move[defender_ind][att_ind]) then cache_this_move[defender_ind][att_ind] = {} end
if (not cache_this_move[defender_ind][att_ind][dst_ind]) then
-- Get the base rating
local base_rating, def_rating, att_rating, att_stats, def_stats =
battle_calcs.attack_rating(attacker, defender, tmp_dsts[i], {}, cache )
tmp_attacker_ratings[i] = att_rating
tmp_att_stats[i], tmp_def_stats[i] = att_stats, def_stats