ActorAI.lua 105 KB
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 1558 1559 1560 1561 1562 1563 1564 1565 1566 1567 1568 1569 1570 1571 1572 1573 1574 1575 1576 1577 1578 1579 1580 1581 1582 1583 1584 1585 1586 1587 1588 1589 1590 1591 1592 1593 1594 1595 1596 1597 1598 1599 1600 1601 1602 1603 1604 1605 1606 1607 1608 1609 1610 1611 1612 1613 1614 1615 1616 1617 1618 1619 1620 1621 1622 1623 1624 1625 1626 1627 1628 1629 1630 1631 1632 1633 1634 1635 1636 1637 1638 1639 1640 1641 1642 1643 1644 1645 1646 1647 1648 1649 1650 1651 1652 1653 1654 1655 1656 1657 1658 1659 1660 1661 1662 1663 1664 1665 1666 1667 1668 1669 1670 1671 1672 1673 1674 1675 1676 1677 1678 1679 1680 1681 1682 1683 1684 1685 1686 1687 1688 1689 1690 1691 1692 1693 1694 1695 1696 1697 1698 1699 1700 1701 1702 1703 1704 1705 1706 1707 1708 1709 1710 1711 1712 1713 1714 1715 1716 1717 1718 1719 1720 1721 1722 1723 1724 1725 1726 1727 1728 1729 1730 1731 1732 1733 1734 1735 1736 1737 1738 1739 1740 1741 1742 1743 1744 1745 1746 1747 1748 1749 1750 1751 1752 1753 1754 1755 1756 1757 1758 1759 1760 1761 1762 1763 1764 1765 1766 1767 1768 1769 1770 1771 1772 1773 1774 1775 1776 1777 1778 1779 1780 1781 1782 1783 1784 1785 1786 1787 1788 1789 1790 1791 1792 1793 1794 1795 1796 1797 1798 1799 1800 1801 1802 1803 1804 1805 1806 1807 1808 1809 1810 1811 1812 1813 1814 1815 1816 1817 1818 1819 1820 1821
-- TE4 - T-Engine 4
-- Copyright (C) 2009 - 2019 Nicolas Casalini
--
-- This program is free software: you can redistribute it and/or modify
-- it under the terms of the GNU General Public License as published by
-- the Free Software Foundation, either version 3 of the License, or
-- (at your option) any later version.
--
-- This program is distributed in the hope that it will be useful,
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-- GNU General Public License for more details.
--
-- You should have received a copy of the GNU General Public License
-- along with this program.  If not, see <http://www.gnu.org/licenses/>.
--
-- Nicolas Casalini "DarkGod"
-- darkgod@te4.org

require "engine.class"
local ActorAI = require "engine.interface.ActorAI"
local DamageType = require "engine.DamageType"
local ActorResource = require "engine.interface.ActorResource"
local Astar = require "engine.Astar"
local Talents = require "engine.interface.ActorTalents"

--- Additional AI functions
--module(..., package.seeall, class.inherit(Base))

module(..., package.seeall, class.inherit(ActorAI))

--config.settings.log_detail_ai = 0 -- debugging general output for AI messages

--- dgdgdgdgdg REMOVE THIS SECTION after the transitional AI phase ---
-- soft switch enabling new AIs during transition phase
-- set true to redirect "tactical" to "improved_tactical" and "dumb_talented_simple" to "improved_talented_simple"
config.settings.ai_transition = true -- set for transition AIs

function _M:init(...)
	ActorAI.init(self, ...)
end

--- Overloaded to force certain NPC's to use the new AI's
--- Run an AI for an actor
-- @param ai the name of the ai to run
-- @param ... additional arguments to be passed to the AI
-- @return the result of self.ai_def[ai](self, ...)
function _M:runAI(ai, ...)
	if not (ai and self.ai_def[ai]) then
		print("[runAI] UNDEFINED AI", ai, "for", self.uid, self.name)
		return
	elseif not self.x then
		print("[runAI] CANNOT RUN AI for actor", self.uid, self.name, "(no location)")
		return
	end

	-- allows Actor specific log detail level
	local old_detail = config.settings.log_detail_ai
	if self.log_detail_ai ~= nil then config.settings.log_detail_ai = self.log_detail_ai end
if config.settings.ai_transition then
	-- debugging: force use of "improved_tactical" in place of "tactical" and "improved_talented_simple" in place of "dumb_talented_simple" for all actors
	if ai == "tactical" then ai = "improved_tactical"
	elseif ai == "dumb_talented_simple" then ai = "improved_talented_simple"
	end -- debugging end
	
	-- force use of "improved_tactical" in place of "tactical" for randbosses
	if self.randboss and ai == "tactical" then ai = "improved_tactical"
	-- force use of "improved_talented_simple" in place of "dumb_talented_simple" for elites
	elseif self.rank >= 3 and ai == "dumb_talented_simple" then ai = "improved_talented_simple"
	end
end
	if config.settings.log_detail_ai > 2.5 then print("[ActorAI:runAI(transitional)] turn:", game.turn, self.uid, self.name, "running AI:", ai, ...) end
	local ret1, ret2, ret3 = _M.ai_def[ai](self, ...)
	config.settings.log_detail_ai = old_detail
	return ret1, ret2, ret3
end
--- dgdgdgdgdg END TRANSITIONAL CODE section ---

--- Master list of tactics defined for the tactical AI
-- Each TACTIC is assigned a benefit coefficient (multiplier) according to the nature of the TACTIC (how beneficial it is to its intended target(s)):
-- val > 0 (usually +1) -> BENEFICIAL: good when it affects self and allies, bad when it affects foes
-- val < 0 (usually -1) -> HARMFUL: bad when it affects self and allies, good when it affects foes
_M.AI_TACTICS = {attackarea=-1, areaattack=-1, attack=-1, closein=-1, escape=-1, surrounded=-1,  disable=-1, attackall=-1,
	cure=1, heal=1, special=1, defend=1, protect=1, ammo=1, feedback=1, buff=1, none=0,
	_no_tp_cache=false, -- non-TACTIC flag: do not store results in the turn_procs cache
	__wt_cache_turns=false -- non-TACTIC setting: maximum number of game turns to store tactical weight cache
	}

--- Additional methods for calculating WANT VALUEs for the tactical AI
-- Each should be a number or a function(self, want, actions, avail, fight_data)
-- calculated WANT VALUEs should range from -10 to +10, and be 0 or negative when the TACTIC isn't useful
-- See the tactical AI for an explanation of want values and the actions, avail, and fight_data tables
_M.AI_TACTICS_WANTS = {}

--- Default bonus per character level for AI actions in the tactical AI
--  AI action tactical weights are multiplied by (1+self.level*self.AI_TACTICAL_AI_ACTION_BONUS)
--  See --== Final Action Evaluation and Selection ==-- in the tactical AI
_M.AI_TACTICAL_AI_ACTION_BONUS = 0.02

--- Default bonus per (raw) talent level for Talent actions in the tactical AI
--  AI talent TACTICAL WEIGHTs are multiplied by (1+self.talents[tid]*self..AI_TACTICAL_TALENT_LEVEL_BONUS)
--  See --== Final Action Evaluation and Selection ==-- in the tactical AI
_M.AI_TACTICAL_TALENT_LEVEL_BONUS = 0.2

--- size of random weight bonus added to actions by the tactical AI (higher makes actions more random)
-- 0.5 --> a bonus of 0% to 50% added to the TACTICAL SCORE for each action
-- replaced by ai_state.tactical_random_range if present
_M.AI_TACTICAL_RANDOM_RANGE = 0.5

--- Maximum # of game turns before refreshing advanced tactical functions cached data for all talents
-- Affects cached actor weights for talent tactics (aiTalentTactics)
-- (100 game turns == 10 actions for normal global speed)
_M.AI_TACTICAL_CACHE_TURNS = 100

--- AI resource parameters
_M.AI_RESOURCE_LEVEL_TRIGGER = 0.9 -- Minimum resource level (fraction of maximum capacity) before the AI will try to replenish it (used by aiResourceAction)
_M.AI_RESOURCE_USE_EST = 0.05 -- Fraction of standard resource pool assumed to be used each turn by the AI, affects various resource valuations
_M.AI_SUSTAIN_TALENT_RESOURCE_THRESHOLD = 10 -- (for simple AI's) Minimum (estimated) number of turns a sustained talent can be maintained below which the AI may deactivate it or (possibly) refuse to activate it

--[[
-- Consider a variable to allow dumb AI's to consult talent tactical tables?
-- This would allow actors with simple AI's to evaluate tactical tables when picking talents
_M.AI_SIMPLE_USE_TACTICAL_VALUES = false
--]]

local tactical_ai_init = false

--- Initialize some AI data (after addons, etc. from tome.load, mostly for optimization)
-- parses each talent definition
-- adds resource TACTICs (+1 benefit coefficient) for each defined resource
-- outputs a summary of all TACTICs detected in talent tactical tables
function _M.AI_InitializeData()
	print("[AI_InitializeData] updating talent definitions and data for AIs")
	-- update master tactics list for resources
	for i, res_def in ipairs(ActorResource.resources_def) do
		_M.AI_TACTICS[res_def.short_name] = _M.AI_TACTICS[res_def.short_name] or 1
	end
	for tid, t in pairs(Talents.talents_def) do -- parse each talent, setting some values
		_M.aiParseTalent(t)
	end
	tactical_ai_init = true
	print("[AI_InitializeData] DETECTED TALENT TACTICS:")
	print("\tTACTIC\t\t\tBENEFIT\t\tWANT CALCULATION")
	for tact, _ in pairs(Talents.ai_tactics_list) do
		local want = "AI auto"
		local swant = _M.AI_TACTICS_WANTS[tact]
		_M.AI_TACTICS[tact] = _M.AI_TACTICS[tact] == nil and -1 or _M.AI_TACTICS[tact]
		if swant then
			if type(swant) == "function" then
				local info = debug.getinfo(swant)
				want = "function @:"..tostring(info.short_src).." line "..tostring(info.linedefined)
			else
				want = "constant:"..tostring(swant)
			end
		end
		print(("\t* %-15s\t%s\t\t%s"):format(tact, tostring(_M.AI_TACTICS[tact]), want))
	end
end

--- parse a talent, adding some values for later AI use
-- @param t a talent definition
-- @param who (optional) target actor
-- adds the tables:
-- _may_restore_resources if the talent has (possible) negative resource costs
-- _may_drain_resources if the talent is sustainable and has (possible) resource drains defined
-- sets a flag, _may_heal if the talent may (possibly) heal
function _M.aiParseTalent(t, who)
	t._ai_parsed = false
	if t.mode == "passive" or t.is_object_use then -- simple AI's will not consider object use talents
		t._ai_parsed = true
	else --  debugging: Note: doesn't look for t.tactical_imp field
		local tactical
		if type(t.tactical) == "function" then
			local ok
			ok, tactical = pcall(t.tactical, who, t, who) -- (test function with who as target, only looking for fields)
			if not ok then -- defer parsing without proper input
				print("[aiParseTalent] FAILED TO PARSE tactical table function for", t.id, who and who.uid, who and who.name)
				print("   => because of error", tactical)
				t._ai_parsed = nil return
			end
		else tactical = t.tactical
		end
		for res, res_def in ipairs(ActorResource.resources_def) do
			local r_name = res_def.short_name
			local may_restore = false
			 -- look for the resource in the tactical table (to be evaluated later)
			if tactical then
				if tactical[r_name] or tactical.self and type(tactical.self) == "table" and tactical.self[r_name] then
					may_restore = true
				end
			end
			-- examine resource costs
			if not may_restore and t[r_name] then
				may_restore = type(t[r_name]) == "function" or t[r_name] < 0
			end
			if may_restore then
				t._may_restore_resources = t._may_restore_resources or {}
				t._may_restore_resources[r_name] = true
--print("[aiParseTalent] talent", t.short_name, t.name, "may restore resource", res_def.name) -- debugging
			end
			if t.mode == "sustained" then -- look for resource drains
				if t[res_def.drain_prop] then
--print("[aiParseTalent] talent", t.short_name, t.name, "sustain may drain resource", res_def.name) -- debugging
					t._may_drain_resources = t._may_drain_resources or {}
					t._may_drain_resources[r_name] = true
				end
			end
		end
		-- look for healing
		if t.is_heal or tactical and (tactical.heal or tactical.self and type(tactical.self) == "table" and tactical.self.heal) then
--print("[aiParseTalent] talent", t.short_name, t.name, "may heal") -- debugging
			t._may_heal = true
		end
		t._ai_parsed = true
	end
end

function _M:staticInitActorAI()
	local Actor = require "mod.class.Actor"

	-- Special attributes contributing to an actor's defensive hash value for the tactical AI (self.aiDHash)
	-- These directly affect the TACTICAL VALUEs of targeted actors (defenders)
	--		changes cause the cache reset
	-- used by Actor:onTemporaryValueChange(prop, v, base)
	-- The defensive hash value is used as a fingerprint by aiTalentTactics when updating its cache
	-- Update as needed (for custom tactics in the tactical AI)
	_M.aiDHashProps = {}
	local DHashProps = {"aiDHashvalue", "fly", "levitation", "never_move", "encased_in_ice", "stoned", "invulnerable", "negative_status_effect_immune", "mental_negative_status_effect_immune", "physical_negative_status_effect_immune", "spell_negative_status_effect_immune", "lucid_dreamer"}
	for typ, tag in pairs(Actor.StatusTypes) do
		table.insert(DHashProps, type(tag) == "string" and tag or typ .. "_immune")
	end
	for i, prop in ipairs(DHashProps) do -- use random values to keep these variables independent
		_M.aiDHashProps[prop] = rng.float(0, 1)
	end

	-- Special attributes contributing to an actor's offensive hash value for the tactical AI (self.aiOHash)
	-- These directly affect the TACTICAL VALUEs of actors targeting other actors (attackers)
	--		changes cause the cache reset
	-- used by Actor:onTemporaryValueChange(prop, v, base)
	-- The offensive hash value is used as a fingerprint by aiTalentTactics when updating its cache
	-- Update as needed (for custom tactics in the tactical AI)
	_M.aiOHashProps = {}
	local OHashProps = {"aiOHashvalue", "disarmed", "additional_melee_chance"}
	for i, prop in ipairs(OHashProps) do -- use random values to keep these variables independent
		_M.aiOHashProps[prop] = rng.float(0, 1)
	end
end

--- Substitute DamageTypes
-- These are predefined methods that can replace DamageType labels in tactical tables.
-- Each is a function(self, t, target) that returns: DamageType label or status condition tag, tactical weight multiplier
-- Those defined here are primarily focused on weapons and weapon skill, but more can be added for other effects:
-- "weapon" -- returns mainhand or unarmed weapon DamageType and up to 2x multiplier based on weapon skill
--		returns the result of the "archery" function if the talent is an archery talent
-- "offhand" -- as "weapon" but for offhand (offhand penalty applies, 0 multiplier if unarmed)
-- "archery" -- returns the ammo DamageType and up to 2x multiplier based on launcher weapon skill (or 0 if no archery weapon/ammo)
-- "sleep" -- checks the target's "lucid_dreamer" attribute
_M.aiSubstDamtypes = {
	weapon = function(self, t, target) -- get mainhand DamageType and special weight modifier (or switch to archery)
		if self:getTalentSpeedType(t) == "archery" then return self.aiSubstDamtypes.archery(self, t, target) end
		local wt, DType = 0
		local mh, oh
		if self:attr("warden_swap") then
			mh, oh = self.main_env.doWardenPreUse(self, "dual", true)
		end
		mh = mh or not self:attr("disarmed") and table.get(self:getInven("MAINHAND"), 1)
		mh = mh and not mh.archery and (mh.combat or mh.special_combat) or self:getObjectCombat(nil, "barehand")
		DType = mh.damtype or DamageType.PHYSICAL
		wt = self:combatGetTraining(mh)
		wt = wt and self:combatLimit(self:getTalentLevel(wt), 2, 1, 0, 1.25, 1/self.AI_TACTICAL_TALENT_LEVEL_BONUS) or 1
		local gwf = self:hasEffect(self.EFF_GREATER_WEAPON_FOCUS)
		if gwf then -- adjust weight to account for extra blows from greater weapon focus
			wt = wt*(1 + gwf.chance/100)
		end
		return DType, wt
	end,
	offhand = function(self, t, target) -- get offhand DamageType and special weight modifier (or switch to archery)
		-- check for archery talent
		if self:getTalentSpeedType(t) == "archery" then return self.aiSubstDamtypes.archery(self, t, target, true) end
		local wt, DType = 0, "PHYSICAL"
		local mh, oh
		if self:attr("warden_swap") then
			mh, oh = self.main_env.doWardenPreUse(self, "dual", true)
		end
		oh = oh or not self:attr("disarmed") and table.get(self:getInven("OFFHAND"), 1)
		oh = oh and not oh.archery and (oh.combat or oh.special_combat)
		if oh then
			DType = oh.damtype or DamageType.PHYSICAL
			wt = self:combatGetTraining(oh)
			wt = (wt and self:combatLimit(self:getTalentLevel(wt), 2, 1, 0, 1.25, 1/self.AI_TACTICAL_TALENT_LEVEL_BONUS) or 1)*self:getOffHandMult(oh)
			local gwf = self:hasEffect(self.EFF_GREATER_WEAPON_FOCUS)
			if gwf then -- adjust weight to account for extra blows from greater weapon focus
				wt = wt*(1 + gwf.chance/100)
			end
		end
		return DType, wt
	end,
	archery = function(self, t, target, offhand) -- get archery DamageType and special weight modifier
		local wt, DType = 0, "PHYSICAL"
		local launcher, ammo, oh
		if self:attr("warden_swap") then
			launcher, ammo, oh = self.main_env.doWardenPreUse(self, "bow", true)
		end
		if not launcher or not ammo then
			launcher, ammo, oh = self:hasArcheryWeapon()
		end
		if offhand then launcher = oh end
		if launcher and ammo then
			wt = self:combatGetTraining(launcher.combat)
			wt = wt and self:combatLimit(self:getTalentLevel(wt), 2, 1, 0, 1.25, 1/self.AI_TACTICAL_TALENT_LEVEL_BONUS) or 1
			local recurse = launcher.combat and launcher.combat.talent_on_hit and launcher.combat.talent_on_hit.T_SHOOT and launcher.combat.talent_on_hit.T_SHOOT.chance
			if recurse then wt = wt*(1 + recurse/100) end
			DType = ammo.combat.damtype or DamageType.PHYSICAL
		end
		return DType, wt
	end,
	sleep = function(self, t, target) -- Sleep immunity is modified by the "lucid_dreamer" attribute
		return sleep, target:attr("lucid_dreamer") and 0 or 1
	end
}

-- Still count grids we can potentially shove or swap our way into
function _M:aiPathingBlockCheck(x, y, actor_checking)
	return not self:block_move(x, y, actor_checking) or not actor_checking.canBumpDisplace or not actor_checking:canBumpDisplace(self)
end

-- Can an NPC shove or swap positions with a space occupied by another NPC?
function _M:canBumpDisplace(target)
	if target == game.player then return end
	if target.rank >= self.rank then return false end
	if not target.x then return end
	if self.never_move or target.never_move or target.cant_be_moved then return false end
	if not self.move_others then return false end
	return true
end

--- Move one step towards the given coordinates if possible
-- This tries the most direct route, if not available it checks sides and always tries to get closer
-- If a friendly actor is in the way, try to get it to move (shove_pressure)
-- @param[type=int] x coordinate of the destination
-- @param[type=int] y coordinate of the destination
-- @param[type=boolean] force setting passed to self:move, ignores self.energy
function _M:moveDirection(x, y, force)
	if not (x and y and self.x and self.y) or not force and self:attr("never_move") then return false end
	local log_detail = config.settings.log_detail_ai or 0
	local l = line.new(self.x, self.y, x, y)
	local lx, ly = l()
	if lx and ly then
		-- aiCanPass fails for friendly targets
		if self:aiCanPass(lx, ly) then return self:move(lx, ly, force) end
	
		-- if blocked, evaluate other possible directions
		local l = {}

		-- Find all possible directions to move, including towards friendly targets
		local target = game.level.map(lx, ly, engine.Map.ACTOR)
		if target and self:reactionToward(target) > 0 and self.canBumpDisplace and self:canBumpDisplace(target) then l[#l+1] = {lx,ly, core.fov.distance(x,y,lx,ly)/2+rng.float(0, .1), target} end -- Add straight ahead if shoveable
		local dir = util.getDir(lx, ly, self.x, self.y)
		local sides = util.dirSides(dir, self.x, self.y)
		for _, dir in pairs(sides) do -- sides
			local dx, dy = util.coordAddDir(self.x, self.y, dir)
			if log_detail > 3 then print("[moveDirection] checking grid", dx, dy) end
			if self:aiCanPass(dx, dy) then
				l[#l+1] = {dx,dy, core.fov.distance(x,y,dx,dy)}
			else
				target = game.level.map(dx, dy, engine.Map.ACTOR)
				if target and self:reactionToward(target) > 0 and self.canBumpDisplace and self:canBumpDisplace(target) then
					l[#l+1] = {dx,dy, core.fov.distance(x,y,dx,dy)/2+rng.float(0, .1), target}
				end
			end
		end

		-- Find the best (most direct) direction
		if #l > 0 then
			table.sort(l, function(a,b) return a[3]<b[3] end)
			local target = l[1][4]
			if target then -- Try to shove the blocker aside before trying to swap, this way we favor the NPCs "spreading out"
				if log_detail > 3 then print("[moveDirection]", self.uid, self.name, "attempting to shove", target.name, l[1][1], l[1][2]) end
				local dir = util.getDir(target.x, target.y, self.x, self.y)
				local sides = util.dirSides(dir, target.x, target.y)
				local check_order = {}
				if rng.percent(50) then
					table.insert(check_order, "left")
					table.insert(check_order, "right")
				else
					table.insert(check_order, "right")
					table.insert(check_order, "left")
				end
				if rng.percent(50) then
					table.insert(check_order, "hard_left")
					table.insert(check_order, "hard_right")
				else
					table.insert(check_order, "hard_right")
					table.insert(check_order, "hard_left")
				end
				for _, side in ipairs(check_order) do
					local check_dir = sides[side]
					local sx, sy = util.coordAddDir(target.x, target.y, check_dir)
--	print("[moveDirection] checking shove target pos", check_dir, sx, sy)
					-- move the friendly target if possible
					if target:canMove(sx, sy) and target:move(sx, sy, true) then
						print("[moveDirection]", self.uid, self.name, "moved (shove)", target.uid, target.name, "to", sx, sy)
						self:logCombat(target, "#Source# shoves #Target# aside.")
						return self:move(l[1][1], l[1][2], force)
--	print("attempting to move", self.name, "to", l[1][1], l[1][2])
					end
				end

				-- We failed to shove the blocker, so swap positions with them instead via the default bumpInto behavior
				-- Since swapping only happens with a rank advantage we don't need to worry about swapping back and forth
				return self:move(l[1][1], l[1][2], force)
			else
				return self:move(l[1][1], l[1][2], force)
			end
		end
	end
end

--- Called before a talent is used by (simple) AIs -- determines if the talent SHOULD (not CAN) be used.
-- @param[type=table] talent the talent definition
-- @param[type=boolean] silent no messages will be outputted
-- @param[type=boolean] fake no actions are taken, only checks
-- @return[1] true to continue
-- @return[2] false to stop
-- talent.on_pre_use_ai (if present) always controls
-- checks aiCheckSustainedTalent for sustained talents
function _M:aiPreUseTalent(talent, silent, fake)
	-- don't recalculate results during a turn
	local ret = self.turn_procs.aiPreUseTalent and self.turn_procs.aiPreUseTalent[talent.id]
	if ret == nil then
		if talent.on_pre_use_ai then ret = talent.on_pre_use_ai(self, talent, silent, fake)
		elseif talent.mode == "sustained" then
			ret = self:aiCheckSustainedTalent(talent)
		else ret = true
		end
		ret = ret or false
		self.turn_procs.aiPreUseTalent = self.turn_procs.aiPreUseTalent or {}
		self.turn_procs.aiPreUseTalent[talent.id] = ret
	end
	if config.settings.log_detail_ai > 3 then print("[aiPreUseTalent]", talent.id, ret) end
	return ret
end

--- Additional checks (resource drains) to determine if the AI should toggle a sustainable talent
-- @param t, a (sustained) talent definition
-- @return boolean representing if the talent may be toggled
-- @return sustained table (if active)
-- By default:
--		a sustained talent may be activated (if it won't deactivate another talent) but not deactivated
-- If the talent drains resources (has a resource.drain_prop field), however, each resource is checked.
--		If (when the talent is active) a resource would be depleted:
--		with no aitarget, an inactive talent may not be activated and an active talent may always be deactivated
--		with an aitarget, drain_time (an estimate of how many turns before depleting the resource) is used
--		this drain time includes an estimate of resource use = self.AI_RESOURCE_USE_EST*default pool size
--		(default pool size is (resource.max or 100) - (resource.min or 0))
--		if drain_time > self.AI_SUSTAIN_TALENT_RESOURCE_THRESHOLD, the chance to activate/deactivate is 100%/0%
--		as drain_time decreases below self.AI_SUSTAIN_TALENT_RESOURCE_THRESHOLD:
--		- the chance to activate an inactive talent decreases towards zero
--		- the chance to deactivate an active talent increases towards 100%
function _M:aiCheckSustainedTalent(t)
	local log_detail = config.settings.log_detail_ai or 0
	local is_active = self:isTalentActive(t.id)
	if log_detail > 2 then print("[aiCheckSustainedTalent]", self.uid, self.name, "checking usability", is_active and "active" or "inactive", t.id) end
	local ok = not is_active -- by default, sustains can be activated but not deactivated
	
	-- don't activate if another talent would be deactivated (sustain_slots), unless the AI is smart enough
	if ok and t.sustain_slots and not self.ai_state._advanced_ai and self:getSustainSlot(t.sustain_slots) then
		return false
	end
	-- Note: resources are updated every self.global_speed turns
	if t._may_drain_resources then -- during AI parsing, potential resource drains were detected
		local chance
		local r_invert -- unary value of resource (-1 for inverted values, +1 otherwise)
		local std_pool -- resource standard pool size (default max or 100 - min or 0)
		local t_drain -- resource drain for this talent per action (@ self.global_speed)
		local drain_time -- estimated (self) turns to depletion of resource pool
		local recover_time -- how fast resource would recover std pool size if drain rate is reversed
		local regen -- net regen rate of resource

		for res, _ in pairs(t._may_drain_resources) do
			local res_def = self.resources_def[res]
			t_drain = (util.getval(t[res_def.drain_prop], self, t) or 0)/self.global_speed
			--print("Pre check", res_def.name, "OK set:", ok)
			if t_drain > 0 then -- sustain depletes this resource
				regen = self[res_def.regen_prop] or 0
				r_invert = res_def.invert_values and -1 or 1
				if log_detail > 2 then print(("[aiCheckSustainedTalent] Talent %s[%s] (%s) drains: %0.2f %s -- vs %0.2f regen"):format(t.name, t.id, is_active and "active" or "inactive", t_drain, res_def.name, regen)) end
				if self.ai_target.actor then  -- in combat, chance to activate/deactivate depends on the time to deplete the resource
					std_pool = (res_def.max or 100) - (res_def.min or 0)
					drain_time = self.AI_SUSTAIN_TALENT_RESOURCE_THRESHOLD
					recover_time = self.AI_SUSTAIN_TALENT_RESOURCE_THRESHOLD
					if is_active then -- chance to turn OFF increases with depletion rate
						if res_def.invert_values then
							if self[res_def.maxname] and regen >= 0 then
								drain_time = (self[res_def.maxname] - self[res_def.getFunction](self))/(regen + std_pool*self.AI_RESOURCE_USE_EST) -- includes estimated resource use per turn
								recover_time = std_pool/t_drain
							end
						else
							if self[res_def.minname] and regen <= 0 then
								drain_time = (self[res_def.getFunction](self) - self[res_def.minname])/(-regen + std_pool*self.AI_RESOURCE_USE_EST) -- includes estimated resource use per turn
								recover_time = std_pool/t_drain
							end
						end
						-- increased chance to deactivate high-drain talents
						chance = 1/(1/drain_time + 1/recover_time)
						if log_detail > 3 then print(" ->", self[res_def.getFunction](self), res_def.name, "depleted (active) in", drain_time, "turns", t.id, "recovery factor:", recover_time, "turns", "deactivate chance: 1 in", chance) end
						if drain_time < self.AI_SUSTAIN_TALENT_RESOURCE_THRESHOLD and rng.chance(chance) then ok = true break end
					else -- chance to turn ON decreases with depletion rate
						--compute prospective regen rate and available pool after the talent is activated
						regen = regen - r_invert*t_drain
						local cost = util.getval(t[res_def.sustain_prop], self, t) or 0
						if res_def.invert_values then
							if self[res_def.maxname] and regen >= 0 then
								drain_time = (self[res_def.maxname] - math.max(self[res_def.getFunction](self), (self[res_def.miname] or 0) + cost))/(regen + std_pool*self.AI_RESOURCE_USE_EST) -- includes estimated resource use per turn
								recover_time = std_pool/t_drain
							end
						else
							if self[res_def.minname] and regen <= 0 then
								drain_time = (math.min(self[res_def.getFunction](self), (self[res_def.maxname] or 100) - cost) - self[res_def.minname])/(-regen + std_pool*self.AI_RESOURCE_USE_EST) -- includes estimated resource use per turn
								recover_time = std_pool/t_drain
							end
						end
						-- decreased chance to activate high-drain talents
						chance = 1/math.max(0, (1-(1/drain_time + 1/recover_time)))
						if log_detail > 3 then print(" ->", self[res_def.getFunction](self), res_def.name, "depleted (inactive) in", drain_time, "turns", t.id, "recovery factor:", recover_time, "turns", "activate chance: 1 in", chance) end
						if drain_time < self.AI_SUSTAIN_TALENT_RESOURCE_THRESHOLD and not rng.chance(chance) then ok = false break end
					end
				else -- out of combat: keep deactivated talents that can't be sustained indefinitely
					if is_active and regen*r_invert < 0 then
						ok = true break
					-- do not turn the talent on if the resource would be depleted
					elseif not is_active and (regen - t_drain*r_invert)*r_invert < 0 then
						ok = false break
					end
				end
			end
		end -- end resource loop
	end
	if log_detail > 2 then print("[aiCheckSustainedTalent]:",self.name, self.uid, ok and "::ai SHOULD" or "::ai SHOULD NOT", is_active and "de-activate" or "activate", t.name, t.id) end
	return ok, is_active
end

local resource_def, resource_vals -- variables for aiGetResourceTalent

-- filter to find talents to replenish a resource (used by aiGetResourceTalent, for simple AIs)
-- updates a table of weights based on tactical tables values
-- calls aiTalentTactics to evaluate the associated resource tactic for sustained talents that drain resources
_M.AI_RESOURCE_TALENT_FILTER = {
	properties_include = {"_may_restore_resources", "_may_drain_resources"},
	special = function(t, self)
		local val
		-- weight = tactical value or 1 if it replenishes resources
		-- check tactical table for the resource
		if t.tactical and (t._may_restore_resources and t._may_restore_resources[resource_def.short_name] or t._may_drain_resources and t._may_drain_resources[resource_def.short_name]) then 
			val = self:aiTalentTactics(t, self.ai_target.actor, nil, resource_def.short_name)
		end
		if not val or val == 0 then -- look for negative resource cost or drains
			if t.mode == "activated" and t._may_restore_resources and t[resource_def.short_name] and (util.getval(t[resource_def.short_name], self, t) or 0) < 0 then val = 1
			elseif t.mode == "sustained" and t._may_drain_resources and t[resource_def.drain_prop] and (util.getval(t[resource_def.drain_prop], self, t) or 0) < 0 then val = 1
			end
		end
		if val and val > 0 then
			resource_vals[t.id] = val
			return true
		end
	end
}

--- Randomly pick a talent to replenish a resource, choosing from all known talents or a list (for simple AIs)
-- @param res_def the resource short_name or definition for the resource that needs replenishing
-- @param t_filter <optional, default self.AI_RESOURCE_TALENT_FILTER> filter passed to _M:aiGetAvailableTalents
-- 		with environment set: resource_def = resource definition, resource_vals = table of weights
-- @param t_list <optional> list of talent id's to consider, defaults to self.talents
-- @return a tid for a talent than can restore the resource or nil
-- available talents are weighted according their tactical value or 1 if they have a negative resource cost
function _M:aiGetResourceTalent(res_def, t_filter, t_list)
	if type(res_def) == "string" then res_def = self.resources_def[res_def] end
	if not res_def then return end
	resource_def = res_def resource_vals = {} -- initialize locals for filter
	
	t_filter = t_filter or self.AI_RESOURCE_TALENT_FILTER
	if t_fliter ~= _M.AI_RESOURCE_TALENT_FILTER and t_filter.special then
		setfenv(t_filter.special, setmetatable({resource_def=resource_def, resource_vals=resource_vals}, {__index=_G}))
	end

	local avail = self:aiGetAvailableTalents(nil, t_filter, t_list)
	if #avail > 0 then
		local total = 0
		for i = 1, #avail do resource_vals[avail[i]] = resource_vals[avail[i]] or 1 total = total + resource_vals[avail[i]] end
		local pick = rng.float(0, total)
		for i = 1, #avail do
			pick = pick - resource_vals[avail[i]]
			if pick <= 0 then
				return avail[i], avail, resource_vals
			end
		end
	end
end

--- Find an action to perform to replenish a resource (called by the maintenance AI)
-- The action may be to use a talent or to invoke an AI
-- @param res_def the resource short_name or definition for the resource that requires action
-- @param t_filter <optional> talent filter passed to aiGetResourceTalent
-- @param t_list <optional> list of talent id's to consider, defaults to self.talents
-- by default, if a resource is at least 10% (self.AI_RESOURCE_LEVEL_TRIGGER) depleted, will try to find a talent to replenish it
--  self.ai_state parameters used:
--		.no_talents == true or non 0 -> never look for available talents
-- @return an action table (or nil) format:
--		talent format:	{tid=<talent id>, force_target=<optional target for the talent, defaults to self>}
--		ai format:		{ai=<ai to invoke>, ... <list of arguments to the ai>}
-- Returns the result of util.getval(resource_def.ai.aiResourceAction, self, resource_def, t_list) if it is defined
-- note: depleted air will trigger a search for a non-suffocating tile (see air resource definition)
function _M:aiResourceAction(res_def, t_filter, t_list)
	if type(res_def) == "string" then res_def = self.resources_def[res_def] end
	if config.settings.log_detail_ai > 2 then print("[aiResourceAction]: called for", self.uid, self.name, res_def and res_def.short_name) end
	if not res_def then return end
	if res_def.ai and res_def.ai.aiResourceAction ~= nil then -- use resource specific method to find an action
		return util.getval(res_def.ai.aiResourceAction, self, res_def, t_filter, t_list)
	end

	if (not self.ai_state.no_talents or self.ai_state.no_talents == 0) then	-- look for a talent
		local val, min, max = self[res_def.short_name], self[res_def.minname], self[res_def.maxname]
		if val and min and max then
			if (res_def.invert_values and max - val or val - min) < (max - min)*self.AI_RESOURCE_LEVEL_TRIGGER then  -- try to replenish
				local tid = self:aiGetResourceTalent(res_def, t_filter, t_list)
				if config.settings.log_detail_ai > 2 then print("[aiResourceAction]:", self.name, self.uid, "found talent", tid, "to replenish", res_def.name) end
				if tid then
					return {name = res_def.short_name, tid=tid}
				end
			end
		end
	end
end

local aitarget = "none"
--- Filter to find talents to heal self (used by aiHealingAction for simple AIs)
_M.AI_HEALING_TALENT_FILTER={
	properties = {"_may_heal"},
	special = function(t, self)
		--print("aitarget=", aitarget)
		local val = self:aiTalentTactics(t, aitarget, nil, "heal")
		return val and val > 0 
	end
}

--- Find an action to heal self (called by the maintenance AI)
-- may move if standing in harmful terrain (aiFindSafeGrid)
-- @param target <optional, defaults to self> target for the heal (talents)
-- @param t_filter <optional, default self.AI_HEALING_TALENT_FILTER> filter passed to _M:aiGetAvailableTalents
-- 		with environment set: aitarget = target (argument to this function)
-- @param t_list <optional> list of talent id's to consider, defaults to all known
--  self.ai_state parameters used:
--		.no_talents == true or non 0 -> never look for available talents
-- @return nil or action <table, containing information on the action to perform> with format:
--		talent format:	{tid=<talent id>, force_target=<optional target for the talent, defaults to self>}
--		ai format:		{ai=<ai to invoke>, ... <list of arguments to the ai>}
function _M:aiHealingAction(target, t_filter, t_list)
	if config.settings.log_detail_ai > 2 then print("[aiHealingAction]: called for", self.uid, self.name) end
	aitarget = target or self
	t_filter = t_filter or self.AI_HEALING_TALENT_FILTER
	if t_filter ~= _M.AI_HEALING_TALENT_FILTER and t_filter.special then
		setfenv(t_filter.special, setmetatable({aitarget=aitarget}, {__index=_G}))
	end
	local tid
	if (not self.ai_state.no_talents or self.ai_state.no_talents == 0) then
		tid = rng.table(self:aiGetAvailableTalents(aitarget, t_filter, t_list))
	end
	-- if standing on a damaging grid, 50% chance to move away from it
	if self:aiGridDamage() > 0 and (not tid or rng.chance(2)) then
		local grid = self:aiFindSafeGrid()
		if grid then return {ai="move_safe_grid", name="move from grid hazard", grid} end
	end

	return tid and {tid=tid, name="self_heal"}, avail, t_filter
end

--- calculate net damage (after applying resistance and affinity) and air loss for standing in a grid
-- @param gx, gy grid coordinates (defaults to self.x, self.y)
-- @return net damage
-- @return air loss per turn
function _M:aiGridDamage(gx, gy)
	gx = gx or self.x
	gy = gy or self.y
	local g = game.level.map(gx, gy, engine.Map.TERRAIN)
	if not g then return 0, 0 end
	local dam, air = 0, 0
	if not self:attr("no_breath") then -- check for suffocating terrain
		local air_level, air_condition = g:check("air_level", gx, gy), g:check("air_condition", gx, gy)
		if air_level and air_level < 0 and (not air_condition or not self.can_breath[air_condition] or self.can_breath[air_condition] <= 0) then
			air = air_level
		end
	end
	
-- can check map effects here also? (need to be standardized, particularly w.r.t. damage types)

	if g.DamageType and g.on_stand and not self:attr("invulnerable") then -- check for damaging terrain
		if not g.faction or self:reactionToward(g) < 0 then
			if type(g.maxdam) == "table" or type(g.mindam) == "table" then dam = 0
			else dam = ((g.maxdam or 0) + (g.mindam or 0))/2 * (100 - self:combatGetResist(g.DamageType)-self:combatGetAffinity(g.DamageType))/100
			end
		end
	end
	if config.settings.log_detail_ai > 3 then print(("[aiGridDamage] for %s (%d, %d) dam: %s, air: %s"):format(self.name, gx, gy, dam, air)) end
	return dam, air
end

--- Calculate the hazard level for a grid (used by aiFindSafeGrid)
-- @param gx, gy = <default self.x, self.y> grid coords to test
-- @param dam_wt <optional, default 1, minimum 0.1> grid weight for damage (as % life lost)
-- @param air_wt <optional, default 1, minimum 0.1> grid weight for air loss (as % air lost)
-- @return hazard value for the grid based on grid damage and air loss computed by aiGridDamage
--	Lower values are safer/better (<= 0 is safe) -- calculation:
--	 val = %health lost*dam_wt + %air lost*air_wt
function _M:aiGridHazard(gx, gy, dam_wt, air_wt)
	local dam, air = self:aiGridDamage(gx or self.x, gy or self.y)
	-- values increase in proportion to the % life lost + % air depleted by the grid
	local val = math.max(0.1, dam_wt or 1)*dam*100/(self.life-self.die_at) - math.max(0.1,( air_wt or 1))*air*100/(self.air + 1)
	return val, dam, air
end

--- Find a (nearest, reachable) safe grid (based on terrain damage and air levels, possibly approaching or avoiding ai target)
-- uses Astar pathing
-- @param radius <optional, default 10> radius to search
-- @param dam_wt <optional, default 1> grid weight for damage
-- @param air_wt <optional, default 1> grid weight for air loss
-- @param dist_weight <optional, default 1 (with ai target) or 0.1> = weight per move needed to reach a grid, (use zero to ignore distance)
-- @param want_closer <optional, default 0.5> weight multiplier for grid distance to ai target (positive values favor grids closer to the target)
-- @param ignore_blocked <optional, default false> set true to search (unreachable) grids beyond blocking terrain
-- @param grid_hazard <optional, function, defaults to self.aiGridHazard> = grid hazard function(self, gx, gy, dam_wt, air_wt)
--	 computes the relative hazard level of each grid, lower is better (0 or less is safe)
-- @return grid found <table> : {
--		[1]=<x coordinate>, [2]=<y coordinate>,
--		val = <net grid value>, start_haz = <start grid hazard value>, end_haz = <end grid hazard value>,
--		move_cost = relative movement cost per grid (compared to std action with global speed 1)
-- 		dist=<path length to reach grid>,
--		Astar = <Astar pathing object (if reachable)>,
--		path = <Astar node_list (if reachable)>}
-- Grid value calculation:
-- 	grid value = grid_hazard(self, x, y, dam_wt, air_wt) + distance*dist_weight/movement speed + want_closer*distance to target
-- 	Always returns the current grid if safe (grid hazard <= 0)
function _M:aiFindSafeGrid(radius, dam_wt, air_wt, dist_weight, want_closer, ignore_blocked, grid_hazard)
	grid_hazard = grid_hazard or self.aiGridHazard
	local haz, dam, air = grid_hazard(self, self.x, self.y, dam_wt, air_wt)
	local val = haz
	local move_cost = self:combatMovementSpeed()/(self.global_speed or 1)-- doesn't take destination terrain speed into account
	local grid = {self.x, self.y, move_cost=move_cost, dist=0, val=val, start_haz=haz, end_haz=haz, dam=dam, air=air}
	if val <= 0 or self:attr("never_move") then return grid end -- already on a safe grid or can't move (enemies don't matter in this case)
	local aitarget, tx, ty = self.ai_target.actor
	dist_weight = (dist_weight or (aitarget and 1 or .1))*move_cost -- less time pressure out of combat
	local log_detail = config.settings.log_detail_ai or 0
	-- possible improvement: update ast.heuristicCloserPath function _M:heuristicCloserPath(sx, sy, cx, cy, tx, ty) to navigate for minimum intervening grid damage

	if aitarget and want_closer ~= 0 then
		tx, ty = self:aiSeeTargetPos(aitarget)
		want_closer = want_closer or 0.5
		grid.val = haz + math.max(0, want_closer*core.fov.distance(tx, ty, self.x, self.y))
		grid.want_closer = want_closer
	else want_closer = 0
	end
	local best_val = grid.val
	local ast, path = Astar.new(game.level.map, self)
	local dist

	if log_detail > 0 then
		print(("[aiFindSafeGrid]%s searching for safer grids [radius %s from (%s, %s), val = %s], dam_wt=%s, air_wt=%s, dist_weight=%s, want_closer=%s"):format(self.name, radius, self.x, self.y, grid.val, dam_wt, air_wt, dist_weight, want_closer))
if log_detail > 1.4 and config.settings.cheat then game.log("%s #PINK#searching for safer grids [radius %s from (%s, %s), val = %s], dam_wt=%s, air_wt=%s, dist_weight=%s, want_closer=%s", self:getName():capitalize(), radius, self.x, self.y, grid.val, dam_wt, air_wt, dist_weight, want_closer) end -- debugging
	 end
	local grid_count = 0
	core.fov.calc_circle(self.x, self.y, game.level.map.w, game.level.map.h, radius or 10,
		function(_, lx, ly)
			--print(("--checking block for grid #%s (%s, %s)"):format(grid_count, lx, ly))
			-- by default, don't look beyond impassible grids
			if not (ignore_blocked or self:canMove(lx, ly)) then return true end
			-- mark the grid impassible if grids further away cannot possibly be better
			local min_val = (core.fov.distance(self.x, self.y, lx, ly)+1)*dist_weight
			if want_closer ~= 0 then
				min_val = math.max(0, min_val + want_closer*(core.fov.distance(tx, ty, lx, ly)-1))
			end
			if min_val >= best_val then
			--print("...setting block for grid at", lx, ly, min_val, "vs.", best_val)
				return true
			end
		end,
		function(_, lx, ly)
			grid_count = grid_count + 1
			if not self:canMove(lx, ly) then return end
			dist = core.fov.distance(self.x, self.y, lx, ly)
			haz, dam, air = grid_hazard(self, lx, ly, dam_wt, air_wt)
			--print(("--checking grid #%s (%s, %s)[%s] %s(base=%s, dam=%d, air=%d) vs. %s"):format(grid_count, lx, ly, dist, val+dist*dist_weight, val, dam, air, best_val))
			val = haz + math.max(0, dist*dist_weight + (want_closer ~= 0 and core.fov.distance(tx, ty, lx, ly)*want_closer or 0))
			if log_detail > 3 then print(("--checking grid #%s (%s, %s)[%s] val=%4.4f(haz=%4.4f)(dam=%d, air=%d) vs. %4.4f"):format(grid_count, lx, ly, dist, val, haz, dam, air, best_val)) end
			if val <= best_val then -- possible better grid found based on straight line path
				path = ast:calc(self.x, self.y, lx, ly, nil, nil, add_check)
				if path then -- grid is reachable, calculate true value with length of actual path
					dist = #path
					val = haz + math.max(0, dist*dist_weight + (want_closer ~= 0 and core.fov.distance(tx, ty, lx, ly)*want_closer or 0))
					if log_detail > 2 then print(("----possible better grid (%s, %s)[%s] %s(%s)(path, dam=%d, air=%d) vs. %s"):format(lx, ly, dist, val, haz, dam, air, best_val)) end
					if val < best_val then -- update the best grid found
						grid[1], grid[2] = lx, ly
						grid.val, grid.dam, grid.air = val, dam, air
						grid.end_haz = haz
						grid.Astar = ast grid.path = path
						grid.dist = dist
						best_val = val
--game.log("#PINK# --updating best reachable grid to: (%d, %d) (dist: %s, val: %s)", grid[1], grid[2], dist, val)
					end
				end
			end
		end,
	nil)
	if log_detail > 0 then
		print("[aiFindSafeGrid] found best grid:", grid[1], grid[2], "val=", grid.val, grid_count, "grids searched")
if log_detail > 1.4 and config.settings.cheat then game.log("#PINK# --best reachable grid: (%d, %d) (dist: %s, val: %s(%s))", grid[1], grid[2], grid.dist, grid.val, grid.end_haz) end -- debugging
	end
	return grid
end

-- Find a grid to flee to while keeping LOS (from self.ai_target.actor)
-- uses the distanceMap
-- returns true/false (grid found?), grid x, grid y
function _M:aiCanFleeDmapKeepLos()
	if self:attr("never_move") then return false end -- Dont move, dont flee
	if self.ai_target.actor and self.ai_target.actor.distanceMap then
		local act = self.ai_target.actor
		local ax, ay = self:aiSeeTargetPos(act)
		local dir, c
		if self:hasLOS(ax, ay) then
			dir = 5
			c = act:distanceMap(self.x, self.y)
			if not c then return end
		end
		for _, i in ipairs(util.adjacentDirs()) do
			local sx, sy = util.coordAddDir(self.x, self.y, i)
			-- Check LOS first
			if self:hasLOS(ax, ay, nil, nil, sx, sy) then
				local cd = act:distanceMap(sx, sy)
--				print("looking for dmap", dir, i, "::", c, cd)
				if not cd or ((not c or cd < c) and self:canMove(sx, sy)) then c = cd; dir = i end
			end
		end
		if dir and dir ~= 5 then
			local dx, dy = util.dirToCoord(dir, self.x, self.y)
			return true, self.x + dx, self.y + dy
		else
			return false
		end
	end
end

--[[
==== TALENT TACTICAL TABLES ==== (with examples)
Tactical tables are the primary data structures used by the tactical AI (and some other AI's, like maintenance) to determine how useful individual talents are to a talent user.  This section describes their format and how they are interpreted (resolved), and provides guidelines and examples for their construction.  The tactical AI has detailed documentation on how it uses tactical tables to choose useful actions.

In this documentation, terms in ALL CAPS refer to specific variables; e.g. "SELF" refers to the acting NPC running its AI.  Also, section labels with format "-== LABEL ==-" (e.g. "-== SUSTAINED TALENTS ==-") are tagged exactly in the relevant code for text searching.

Some examples are included in the "--==TACTICAL TABLE  EXAMPLES ==--" section below.  Also, the value of config.settings.log_detail_ai can be increased to increase the detail of AI information output to the log file.

For a talent with definition t, the tactical table for a talent is defined in the field t.tactical, which can be either a table or a function(self, t, aitarget) that returns a table.  Here, self refers to the talent user ("SELF") and aitarget is the actor the talent targets ("AITARGET", usually self.ai_target.actor, which may not necessarily be one of the actors affected by it.)

As a typical example, a talent that has defined:

	t.tactical = {attack = {LIGHTNING = 2}}
	
is interpreted as fulfilling the "attack" TACTIC with base effectiveness 2, modified by damage modifiers for the LIGHTNING DamageType.  This table is resolved (based on the targets it may affect) to a summary of TACTIC WEIGHTs:

	{attack = X}
	
where X is a number reflecting the overall effectiveness of the talent at fulfilling the "attack" TACTIC.

This table is further interpreted by the tactical AI, using other factors such as talent level and speed, targeting information, situational weight modifiers, etc., to evaluate how effective the talent is at fulfilling the current needs of the talent user.

TACTICAL TABLE FORMAT:

	{TACTIC1 = WEIGHT1, TACTIC2 = WEIGHT2, ...}

Each TACTIC ("attack", "heal", "escape", etc.) is a label that associates the capabilities of the talent with some possible needs for SELF.  (Note: TACTIC labels are converted from UPPER CASE (in talent definitions) to LOWER CASE (as used by the AI) during talent parsing.  TACTIC labels in full or partial tactical tables returned by functions, should be lower case to be consistent.)

Each WEIGHT is (resolved to) a number, reflecting how effective the talent is at satisfying the corresponding TACTIC.  The full table of TACTIC WEIGHTs reflects all of the tactical uses the tactical AI considers for the talent.

-== INTERPRETATION ==-
When evaluating a talent, the AI generates a list of actor(s) the talent may affect and then resolves the corresponding WEIGHT for each listed actor, summing the results to get the TACTIC WEIGHT. (These tasks are performed, respectively, by the functions engine.interface.ActorAI:aiTalentTargets(t, aitarget, tg, all_targets, ax, ay) and _M:aiTalentTactics(t, aitarget, target_list, tactic, tg, wt_mod), defined in this file.)

Each WEIGHT can be defined as a number, a table, or a function(SELF, t, actor) (A function is called as needed when the tactical table is evaluated by aiTalentTactics, and must return a number or a table.  The actor argument is the actor in the list being affected.)

Each WEIGHT is resolved as follows for each actor:

	First, if WEIGHT is a function(SELF, t, actor), it is computed to get a number or table.
	If WEIGHT is a number, it is used directly. (It will be the same for each actor.)
	If WEIGHT is a table (It may be different for each actor.):
		
			{TYPE1 = VALUE1, TYPE2 = VALUE2, ... }
			
		Each TYPE is evaluated to determine the expected effectiveness of the TACTIC against the actor (on the basis of 1 = 100% effective), to be multiplied by the corresponding resolved VALUE.
		If TYPE is a function or is a label that matches the name of a function in the list SELF.aiSubstDamtypes ("weapon", etc., defined above), that function will be called as function(SELF, t, actor), returning a replacement TYPE (DamageType label or status condition tag) and a WEIGHT multiplier.
		If TYPE is an engine.DamageType label (e.g. "FIRE", "PHYSICAL"):
			The effectiveness is SELF's damage multiplier for the DamageType times the fraction that penetrates the actor's resistances, with any affinity being treated as extra resistance.
		otherwise, if TYPE is a status condition tag (e.g. "stun", "pin"):
			The effectiveness is the percent chance for actor:canBe(TYPE)/100.
		
		Each VALUE is resolved into a number as follows:
		
			If VALUE is a function(SELF, t, actor) it is computed first.
			Then, if it's a number, it's used directly.  Otherwise, if it's a table:
			
				{STATUS1=STATUSVALUE1, STATUS2=STATUSVALUE2, ...}
			
			it is resolved as the weighted sum of all of the STATUSVALUEs (using the chance from actor:canBe(STATUS)/100).
			
		Each TYPE is evaluated independently, and the VALUE of all TYPEs are summed to get the WEIGHT for the actor being considered.

The TACTIC WEIGHT is the sum of all of the computed WEIGHTs for each actor in the list.

To reference resistances correctly, TYPE should be upper case for damage types and lower case for status conditions (e.g. "disarm").  Damage types should all be basic elemental damage types for which resistances are defined, e.g. "PHYSICAL", "COLD", "FIRE", "LIGHTNING", "ACID", "NATURE", "ARCANE", "BLIGHT", "LIGHT", "DARK", "MIND", "TEMPORAL".

If TACTIC == "self", it will be interpreted as an independent set of TACTICs applied only to SELF.  WEIGHT will be interpreted as a completely separate tactical table, and all of its TACTIC WEIGHTs will be merged into the main tactical table after all other TACTICs are computed.  This is useful for talents that have different affects on the user and other actors.

_M.aiSubstDamtypes (defined above) contains predefined functions associated with TYPE labels.  These are used to generate appropriate DamageTypes and multipliers for some common attack types, such as melee attacks.  Each returns an appropriate TYPE (DamageType label or status condition tag) and a WEIGHT multiplier.  The predefined functions are:

	"weapon" -- returns mainhand or unarmed weapon DamageType and a multiplier (1x to 2x) based on weapon skill
		returns the result of the "archery" function if the talent is an archery talent
	"offhand" -- as "weapon" but for offhand (offhand penalty applies)
	"archery" -- returns the ammo DamageType and a multiplier (1x to 2x) based on launcher weapon skill

--BENEFICIAL VS. HARMFUL TACTICS and HOSTILE VS. FRIENDLY TARGETS--
Each defined TACTIC has a numerical benefit coefficient (multiplier) in the table _M.AI_TACTICS.  TACTICs are broadly categorized as either beneficial or harmful, depending on the sign of this value.  Positive coefficients generally mean the TACTIC is good (for SELF) when applied to itself or allies and bad when applied to foes.  Negative coefficients reverse this.  Undefined TACTICs have a default benefit coefficient of 0, and always receive 0 TACTIC WEIGHT.

The TACTIC WEIGHTs for actors affected by a talent are modified by SELF's reaction to them (each is either SELF, an ally, or a foe).  The TACTIC WEIGHTs for actors friendly to SELF that are adversely affected by a harmful TACTIC may be multiplied by compassion values.  These are self_compassion (self.ai_state.self_compassion, default 5) and ally_compassion (self.ai_state.ally_compassion, default 1) for self and allies, respectively.

Note that the AI usually selects an enemy as AITARGET if possible.  The logic for how WEIGHTs are modified by SELF's reaction to each actor depends on the talent's targeting parameters and the TACTIC (This is handled in the "--== TACTIC INTERPRETATION ==--" section of code within the _M:aiTalentTactics function below.):

	If the talent requires a target (AITARGET is the primary target for the talent.):

	AITARGET is assumed to be appropriate for the talent and all (positive) WEIGHTS are treated as useful for the talent user for both beneficial and harmful TACTICs:

		If AITARGET is hostile, then the talent is treated as useful against foes:
			Each beneficial TACTIC is treated as beneficial (for the talent user) for all actors affected.
			Each harmful TACTIC is treated as harmful to each actor affected, and is penalized for hitting allies (compassion applies).
			
		If AITARGET is friendly, then the talent is treated as useful on allies:
			The TACTIC WEIGHT is increased for each ally affected and decreased for each foe affected.

	If the talent does not require a target (It is used on SELF if AITARGET is undefined.):

		If the talent has targeting parameters (SELF:getTalentTarget(t) returns non-nil), then it is designed to affect other actors (possibly, but not necessarily including SELF):
			Each beneficial TACTIC is treated as beneficial to each target affected, and is penalized for hitting foes.
			Each harmful TACTIC is treated as harmful to each target affected, and is penalized for hitting allies (compassion applies).
			
		if the talent has no targeting parameters (SELF:getTalentTarget(t) returns nil), it is designed to affect the user only:
			The talent is assumed to affect the user if no targets are designated.
			Each (positive) WEIGHT is treated as useful to SELF (for both beneficial and harmful TACTICs).

The "special" TACTIC is an exception: it treats all affected targets the same and ignores compassion.
			
--== SUSTAINED TALENTS ==--
Sustained talents get some special treatment.

The TACTIC WEIGHTs for active sustains are negated, since turning a talent off reverses the effects gained from turning it on.  So if the talent has tactical = {buff = 2}, then while active it has tactical = {buff = -2}.

The TACTIC WEIGHTs for active sustains are reduced if the cooldown > 10 and AITARGET is defined (i.e. SELF is assumed to be in combat and the long cooldown may prevent it from being used again.).

If a sustained talent automatically turns off other sustains when activated (it has defined the .sustain_slots field, e.g. celestial chants), the TACTIC WEIGHTs for each talent that would be deactivated will be calculated and subtracted from the final tactical table.

If a sustained talent drains resources while active (e.g. Fearscape drains vim), aiTalentTactics will automatically generate additional (usually negative) resource-based TACTIC WEIGHTs for each drained resource (unless already defined in the tactic table, requires talent[drain_prop] be defined according to the resources definition).  The TACTIC WEIGHT for these tactics are scaled to be 1 for a drain rate of 10% of a standard size pool per turn and are limited to |TACTIC WEIGHT| < 5.  (The standard pool is defined from the resource definition as max <default 100> - min <default 0>.  This is 100 for most resources, but can be specified by setting a value for ai.tactical.default_pool_size when defining the resource.)

In addition, resource drains will cause an estimate to be computed for how long a sustained talent can be maintained.  This is based on the rate the resource(s) are drained, plus an allowance for resource usage (The standard pool*SELF.AI_RESOURCE_USE_EST). If the estimated time is less than 10 turns, all TACTIC WEIGHTs (except those automatically generated for drain resources) will be decreased (for inactive talents) or increased (for active talents).

-== SPECIAL FLAGS ==-
Some flags can be added to tactical tables as special instructions to aiTalentTactics:

	__wt_cache_turns: specify the maximum number of game turns (not actions) to cache TACTIC values for other actors (default SELF.ai_state._tactical_cache_turns or SELF.AI_TACTICAL_CACHE_TURNS)
		This is only needed (typically set to 1) for talents that affect actors other than SELF for which the TACTIC value is likely to change very frequently (e.g. CURE, which depends on temporary status effects, or HEAL, which depends on life levels).  Set to 0 to disable caching TACTIC values.
	_no_tp_cache: set to true to prevent caching of the final TACTIC WEIGHTs in the SELF.turn_procs cache.
		This is useful to prevent storing intermediate results when building complex TACTIC WEIGHT tables in stages.  (Such as when merging various TACTIC WEIGHTs into the tactics.self subtable.  See T_SUN_BEAM and the cunning/poisons and psionic/projection talents for examples.)

-== TACTICS CACHING STRUCTURE ==-
For each talent, the aiTalentTactics function caches a list of targets affected (within SELF.turn_procs) and tactical data for each target.  This allows previously computed TACTIC values for a talent vs possible targets to be remembered, so that they don't need to be recomputed unnecessarily.  The main variables used and their structure are as follows:

	SELF.aiOHash = offensive hash value:
		Used as a fingerprint to trigger reset of most cached tactical information for SELF.
		Updated by Actor:onTemporaryValueChange (when one of the properties in ActorAI.aiOHashProps changes).
	ACTOR.aiDHash = defensive hash value for ACTOR:
		Used as a fingerprint to trigger reset of cached TACTIC values for SELF against a specific ACTOR.
		Updated by Actor:onTemporaryValueChange (when one of the properties in ActorAI.aiDHashProps changes).
	
	computed TACTIC values (for various actors, never for SELF):
	SELF._ai_tact_wt_cache = {
		_computed = game.turn of last full reset,
		[TID] = {
			_computed = game.turn of last reset for TID,
			_OHash = SELF.aiOHash at last reset,
			[TACTIC] = {
				[actor1] = value1, [actor2] = value2, ..., computed TACTIC values indexed by actor reference
				[actor1.uid] = actor1.aiDHash, [actor2.uid] = actor2.aiDHash, ...  defensive hash values matching TACTIC values
				}
			}
		}
		
	final TACTIC WEIGHT results (for each talent evaluated in the current turn):
	SELF._turn_ai_tactical = {_new_tact_wt_cache = <boolean> actor weights cache has been reset,
			[TID] = {base_tacs = {base tactics = computed explicit tactics for TID,
				implicit_tacs = computed implicit tactics for TID},
				selffire = computed selffire coefficient,
				friendlyfire = computed friendlyfire coefficent,
				targets = {list of targets possibly affected by TID},
				tactics = last computed TACTIC WEIGHT table,
				weight_mod = weight mod used for last computed TACTIC WEIGHT table
			}
		}
	}

The soft switch config.settings.tactical_cache_test can be set to force recomputing cached values while checking them against newly computed values.  This triggers extra output to the game log (and incurs a performance penalty).

--==TACTICAL TABLE  EXAMPLES ==--
EXAMPLE 1 -- Step by step interpretation of multiple TACTICs with multiple DamageTypes:
Consider a hypothetical talent with the following tactical table:

	talent.tactical = {ATTACK = {LIGHTNING = 1, NATURE = 2}, DISABLE = {stun = 2}}

During talent parsing, the TACTIC labels are converted to lower case to get the form used by the AI:
	
	talent.tactical = {attack = {LIGHTNING = 1, NATURE = 2}, disable = {stun = 2}}
	
This table is interpreted as:
	
	TACTIC1 = "attack"		WEIGHT1 = {LIGHTNING = 1, NATURE = 2}
	TACTIC2 = "disable"		WEIGHT2 = {stun = 2}

The labels in the WEIGHT1 table are upper case in order to exactly match one of the standard DamageType labels.

If the TACTICs are evaluated against a list of 2 (hostile) actors:
	
	actor 1: (50% resistant to LIGHTNING)
	actor 2: (20% resistant to NATURE, 100% immune to stun)
	
(with no other resistances or other effects), the WEIGHTs for each actor are evaluated as:

	for actor 1: WEIGHT1 = 0.5*1 + 1.0*2 = 2.5		WEIGHT2 	= 1.0*2 = 2
	for actor 2: WEIGHT1 = 1.0*1 + 0.8*2 = 2.6		WEIGHT2 	= 0.0*2 = 0
	totals:		 WEIGHT1 (attack)		 = 5.1		WEIGHT2 (disable)	= 2

So the resolved tactical table for this talent (versus these actors) is:

	TACTIC WEIGHTs = {attack = 5.1, disable = 2}
	
This resolved table is used by the tactical AI to determine how useful the talent is to SELF based on the tactical circumstances. (Larger values reflect more effectiveness.)

Note to talent developers: This example uses slightly inflated TACTIC VALUEs for clarity.  To prevent the AI over or under using a talent, typical WEIGHTs should evaluate, for a single actor, to a maximum of around 2 for most talent's primary TACTIC(s), and to smaller values for secondary TACTICs. (A WEIGHT of 3 is usually treated as VERY effective by the AI.)

EXAMPLE 2 -- Compound resistances (PHYSICAL and NATURE-based poison attack):
Consider a talent (requiring a hostile AITARGET) that deals modest PHYSICAL damage, but that afflicts the target with a powerful poison effect that deals NATURE damage.  The tactical table might look like:

	talent.tactical = {ATTACK = {PHYSICAL = 1, NATURE = {poison = 2}}

If this is applied against a target with 50% PHYSCIAL resistance and 90% NATURE resistance and no poison immunity:

	TACTIC WEIGHT (attack) = 0.5*1 + 0.1*(1.0*2) = 0.5 + 0.2 = 0.7

Against another target with no PHYSICAL or NATURE resistance that is immune to poison:

	TACTIC WEIGHT (attack) = 1.0*1 + 1.0*(0.0*2) = 1 + 0 = 1
	
If the attack would hit 10 of the resistant targets and 10 of the poison-immune targets (all hostile) it would have a base TACTICAL WEIGHT (attackarea) of 10*0.7 + 10*1 = 17:

	TACTIC WEIGHTs = {attack = 17}

EXAMPLE 3 -- multiple targets, mixed DamageTypes (Darkfire talent):
This talent deals 50% FIRE and 50% DARKNESS damage in an area.  It requires a target and AITARGET will be hostile.

	talent.tactical = { ATTACKAREA = {FIRE = 1, DARKNESS = 1}}

Against 3 targets:

	foe1: (immune to FIRE damage)
	foe2: (no resistances)
	ally: (immune to DARKNESS)
	
the TACTICAL WEIGHT for the (harmful) ATTACKAREA tactic is computed as:

	foe1:	(0.0*1 + 1.0*1) 		= 1
	foe2:	(1.0*1 + 1.0*1) 		= 2
	ally:	(1.0*1 + 0.0*1)*-1		= -1
	total:							= 2

The WEIGHT for the ally is multiplied by -1 (-1*ally_compassion), because ATTACKAREA is a harmful TACTIC (_M.AI_TACTICS.attackarea = -1) affecting an ally for a talent that requires a hostile target.

EXAMPLE 4 -- functional TACTIC value, Nature's Touch talent:
This talent heals the target if it's not undead.  It does not require a target, and so is assumed to be used on SELF if AITARGET is undefined.  This means that the tactics are useful when affecting friendly targets.

	talent.tactical = { HEAL = function(self, t, target)
		return not target:attr("undead") and 2*(target.healing_factor or 1) or 0 
	end}

The talent gets a base tactical weight for the HEAL tactic of 0 if the target is undead or 2 times the target's (usually SELF's) healing_factor.  If targeting SELF (not undead), this resolves to:

	TACTICAL WEIGHTs (affecting SELF) = {heal = 2*SELF.healing_factor}
	
EXAMPLE 5 -- combined friendly and hostile effects (Blood Grasp talent):
This talent deals blight damage to a single target and heals the talent user for half the damage done.  It requires a target; AITARGET will be hostile.

	talent.tactical = { ATTACK = {BLIGHT = 1.75}, HEAL = {BLIGHT = 1}}

(Note that TACTIC WEIGHTs are not necessarily proportional to the talent's effects.)  If the target is a foe with no blight resistance, the tactical table resolves to:

	TACTICAL WEIGHTs (affecting a foe) = {attack = 1.75, heal = 1}

The ATTACK TACTIC is detrimental and the HEAL TACTIC is beneficial and so have opposite values in SELF.AI_TACTICS.  Since the talent requires a target, however, and is assumed to be targeted correctly, both TACTICs are treated as useful (to the talent user).

If the target is an ally (with ally_compassion = 1):

	TACTICAL WEIGHTs (affecting an ally) = {attack = -1.75, heal = 1}
	
since the talent would hurt the ally but still heal SELF.  If the talent allowed the same ally and foe to both be affected, the tactical table for hitting both actors would be:

	TACTICAL WEIGHTs (affecting one foe and one ally) = {attack = 0, heal = 2}

since the ATTACK WEIGHTs would offset each other, but the HEAL WEIGHTs would be added.

EXAMPLE 6 -- split effects on self and others (Rune: Lightning talent):
The talent deals moderate lightning damage to a target, while providing a strong defensive effect to the user.  It requires a target (and is targeted on a foe).

	talent.tactical = { SELF = {defend = 2}, ATTACK = { LIGHTNING = 1 } }
	
If it is targeted on a foe that is immune to LIGHTNING damage in such a way that an additional foe and an ally are also hit, the tactical table resolves to:

	tactical = {defend = 2, attack = 0}
	
For the ATTACK TACTIC, the talent target adds no tactical value (it is immune), while the additional foe and ally hit (ally_compassion = 1) offset each other.
The defend tactic applies only to the talent user, regardless of the targets.  It is is applied directly, and only once.

EXAMPLE 7 -- sustained talent affecting others (Body of Fire talent):
This sustained talent continuously fires out flaming bolts in a radius around the user against enemies, provides some fire resistance, burns melee attackers, and drains mana continuously.  (Sustained talents get special treatment in the "--== SUSTAINED TALENTS ==--" section of the aiTalentTactics function.)

	tactical = { ATTACKAREA = { FIRE = 1.5 }, SELF = {defend = 1}}
	
This talent uses t.target to determine which targets are affected each turn.  If there is a single hostile target (no fire resistance) within range, the tactical table resolves to:

	tactical = { attackarea = 1.5, defend = 1, mana = <negative WEIGHT>}
	
where the mana TACTIC is automatically added by SELF.aiTalentTactics.  The <negative WEIGHT> is computed based on the mana drain rate (defined by the talent definition), and the default resource pool size for mana (100).  This assumes that there is enough mana available to maintain the talent for at least 10 turns.  If there is less, the TACTIC WEIGHTs for "attackarea" and "defend," but not "mana" will be reduced.

If the talent is already active, the base tactical table is negated by default:
	
	tactical = { attackarea = -1.5, defend = -1, mana = <positive WEIGHT>}

Turning off talents with long cooldowns is penalized while in combat, however.  This talent's cooldown (of 40 turns) results in a weight modifier of 0.5 ((10/cooldown)^.5).  The base tactical table for the active talent is then:

	tactical = { attackarea = -0.75, defend = -0.5, mana = <positive WEIGHT>*.5}
	
If there is insufficient mana to maintain the talent for at least 10 turns, the magnitude of the attackarea and defend WEIGHTs may be further decreased.
--]]

--- Compute the effectiveness of a tactic effect_type against foes, allies, and self in a list of actors
-- matches functionality in aiTalentTactics, for use by the old tactical ai interpreting updated tactical tables
-- @param targets = actor or list of actors to affect, defaults to current target or self
-- @param effect_type = a number (used directly) or a DamageType or status effect label (checked against resistances of each actor)
-- @param effect_wt =  <optional, default 1> base effectiveness value evaluated for each actor as follows:
-- 		If it's a function, it is evaluated as effect_wt(self, t, actor) or 0, then:
--		If it's a number, is used directly as the weight
--		If it's a table, (i.e. {status1=weight1, status2=weight2,...}),
--			it is evaluated as the sum of all the weights for which actor:canBe(status) returns true
--		The net weight for each actor is then adjusted for selffire, friendlyfire according to reaction
-- @param t = talent definition (for effect_wt function calls)
-- @param selffire, friendlyfire <optional, 0 - 1, default 0> fractional chance to affect self, allies
-- @return effective numbers of foes, self, allies affected by the effect_type, accounting for DamageType resistance/affinity and immunity to status effects
function _M:aiTacticEffectValues(targets, effect_type, effect_wt, t, selffire, friendlyfire)
	effect_wt = effect_wt or 1
	local pen, res = DamageType[effect_type] and util.bound(self:combatGetResistPen(effect_type), 0, 100) or 0, 0
	local log_detail = config.settings.log_detail_ai or 0
	targets = targets or self.ai_target.actor or self
	selffire = (selffire == true and 1) or selffire or 0
	friendlyfire = (friendlyfire == true and 1) or friendlyfire or 0

	local nb_foes_hit, nb_self_hit,	nb_allies_hit = 0, 0, 0
	local weight, act_type
	local done = false
	local i, act
	
	repeat
		if targets.__CLASSNAME then
			act, done = targets, true
		else
			i, act = next(targets, i)
			if not act then break end
		end
		if act == self then -- hit self
			act_type = "self"
			weight = selffire*friendlyfire -- matches actor:project
		elseif self:reactionToward(act) >= 0 then -- hit ally
			act_type = "ally"
			weight = friendlyfire
		else -- hit foe
			act_type = "foe"
			weight = 1
		end
		if weight ~= 0 then
			if log_detail > 2 then print("[aiTacticEffectValues]", self.uid, self.name, "vs", act.uid, act.name, act_type, weight) end
			local effect_type, effect_wt = effect_type, effect_wt
			local _, status_chance
			-- special case for effect_type is a function
			if type(effect_type) == "function" then -- evaluate the function against the actor
--				effect_type = effect_type(self, t, act) or 0
				effect_type, effect_wt = "function", effect_type(self, t, act) or 0
			end
			if type(effect_type) == "number" then -- numerical weights are used directly (no resistances)
				effect_wt = effect_type
			else -- adjust for effective resistance (table)
				if DamageType[effect_type] then -- calculate effective DamageType resistance and affinity
					res = act:combatGetResist(effect_type)
					res = util.bound(res > 0 and res * (100 - pen) / 100 or res, -100, 100) + act:combatGetAffinity(effect_type)
					if log_detail > 2 then print("\t\t_DamageType Modifiers:", effect_type, "pen, res, aff=", pen, res, act:combatGetAffinity(effect_type)) end
				else
					_, status_chance = act:canBe(effect_type) --Status immunity
					if log_detail > 2 then print("\t\t--- status_chance", effect_type, status_chance) end
					res = 100 - status_chance
				end
				if type(effect_wt) == "function" then effect_wt = effect_wt(self, t, act) or 0 end -- evaluate function
				if type(effect_wt) == "table" then -- sum the list of statuses and weights that can affect the actor
					local weight = 0
					for status, wt in pairs(effect_wt) do
						_, status_chance = act:canBe(status) --Status immunity
						if log_detail > 2 then print("\t\t--- status_chance", effect_type, status, status_chance) end
						weight = weight + wt*status_chance/100
					end
					effect_wt = weight
				end
				if log_detail > 2 then print("\traw effect_wt ", effect_type, "against", act.name, effect_wt) end
				effect_wt = effect_wt * (100 - res) / 100 -- apply the effective resistance
			end
			-- update totals
			if act_type == "foe" then
				nb_foes_hit = nb_foes_hit + effect_wt
			elseif act_type == "self" then
				nb_self_hit = nb_self_hit + effect_wt
			else
				nb_allies_hit = nb_allies_hit + effect_wt
			end
			if log_detail >= 2 then print("[aiTacticEffectValues] adjusted effect_wt ", effect_type, "against", act.uid, act.name, effect_wt) end
		end
	until done or not i
	return nb_foes_hit, nb_self_hit, nb_allies_hit
end

--- force reset and testing of cached data in aiTalentTactics
-- for debugging complex tactical tables
config.settings.tactical_cache_test = false
--config.settings.tactical_cache_test = true -- debugging

--- Compute the (resolved) TACTICAL WEIGHT(s) for a talent to determine what TACTICs it fulfils
-- @see the tactical AI
--	WEIGHTs can be positive (good for self) or negative (bad for self)
-- 	uses data contained in the talent definition (t.tactical_imp or t.tactical)
--	See the "==== TALENT TACTICAL TABLES ====" section above for a detailed explanation of how talent tactical tables are interpreted
-- @param t, a talent definition
-- @param aitarget <optional> the AI's target (actor) for the talent
-- @param target_list <optional> = list or single actor (Entity) affected
--		default: self:aiTalentTargets(t, aitarget, tg, true) untargeted beneficial effects assumed to affect self
-- @param tactic <optional, defaults to t.tactical> either a single tactic label <string> to evaluate or
--		a tactical table or function(self, t, aitarget) to evaluate in place of the one defined in the talent
--		changing the tactical info forces a cache reset
-- @param tg <optional, default: return of getTalentTarget> targeting table
--		used to determine targets (if no target_list) for the talent and to define selffire/friendlyfire
-- @param wt_mod <optional> if defined, overrides the multiplier for all tactical weights
--		Use to prevent adjustments for sustained talents (weights always negated for active sustains).
-- Additional (non-parameter) inputs:
--	self.ai_state.self_compassion <default 5> = multiplier for non beneficial tactics applied to self
--	self.ai_state.ally_compassion <default 1> = multiplier for non beneficial tactics applied to allies
--  self.ai_state._tactical_cache_turns <default self.AI_TACTICAL_CACHE_TURNS> = maximum game turns before all actor tactic weight caches are reset
-- @return[1] <table> TACTICAL WEIGHTs {tact1 = <number>, tact2 = <number>, ...}
-- @return[2] <number> the value of a specific tactic (if the tactic argument is a string, i.e. TACTIC label)
-- @return[3] false if no TACTICs could be generated
-- For a sustainable talent:
--		If active:
--			TACTICAL WEIGHTs are negated (i.e. the weights for turning the talent off)
--			TACTICAL WEIGHTs are reduced if the cooldown > 10 (if aitarget is defined)
--		If inactive:
--			TACTICAL WEIGHTs of any talents that would be deactivated (sustain_slots) are subtracted
--			TACTICAL WEIGHTs are reduced if drains would prevent sustaining the talent for at least 10 turns
--		Additional TACTICAL WEIGHTs are generated for drained resources (unless they are already defined in the tactical table or tactic parameter defines a new tactical table):
--			(i.e. t.drain_vim > 0 --> ["vim"]=<negative value>)
function _M:aiTalentTactics(t, aitarget, target_list, tactic, tg, wt_mod)
	-- set up caching for tactical values
	local cache_turns = self.ai_state._tactical_cache_turns or self.AI_TACTICAL_CACHE_TURNS -- # game turns cached values are valid (all talents)
	local cache_wt_values -- allow caching of tactical weights in self._ai_tact_wt_cache
	local tp_cache_tactics -- allow caching of tactics results in self._turn_ai_tactical
	self._turn_ai_tactical = self._turn_ai_tactical or {_new_tact_wt_cache = false}
	local tp_cache = self._turn_ai_tactical -- turn_procs tactical cache
	local tpid_cache = tp_cache[t.id] -- turn_procs tactical cache (this talent)
	local log_detail = config.settings.log_detail_ai or 0
	
	local force_cache_test = config.settings.tactical_cache_test
	
	if log_detail > 1 then print(("[aiTalentTactics] COMPUTING TACTICs [%d]%s(OHash:%s) (%s, wt_mod:%s, wt cache_turns:%d) for talent: %s targeted on %s[%s], tg=%s"):format(self.uid, self.name, self.aiOHash, tactic or "all", wt_mod, cache_turns, t.id, aitarget and aitarget.uid, aitarget and aitarget.name, tg)) end 

	-- build/reset the master target tactical weight cache periodically
	if not self._ai_tact_wt_cache or game.turn - self._ai_tact_wt_cache._computed >= cache_turns and not tp_cache._new_tact_wt_cache then
		if log_detail > 2 then print("[aiTalentTactics] *** creating new TACTICAL WEIGHT CACHE, turn", game.turn) end
		self._ai_tact_wt_cache = {_computed=game.turn}
		tp_cache._new_tact_wt_cache = true
	end

	local tac_cache = self._ai_tact_wt_cache -- master target tactical weight cache
	local tid_cache -- target tactical weight cache (this talent)
	local cache_tactic = type(tactic) ~= "string" and tactic or nil -- tactical data reference
	local targets = target_list -- specified target list	
	-- initialize the tactical weight cache for this talent if the tactical table (tactic argument) has changed
	local reset_cache = not tac_cache[t.id] or cache_tactic ~= tac_cache[t.id].tactic
	local tactical, no_implicit -- final resolved tactical table, flag to exclude implicit tactics
	local requires_target = self:getTalentRequiresTarget(t)
	local hostile_target = aitarget and (self:reactionToward(aitarget) < 0 or aitarget:attr("encased_in_ice"))
	local is_active = self:isTalentActive(t.id)
	local weight_mod = 1 -- master tactic weight modifier
	local tactics, has_tacs
	local implicit_tacs, deac_tactics --implicit (resource) tactics, and tactics for sustains that would need to be deactivated

	local cache_tactics -- cache values
	
	if force_cache_test and log_detail > 2 then -- output cache summary
		print("[aiTalentTactics] _**_ summary of CACHED DATA", self.uid, self.name, t.id) table.print(tpid_cache, "\t_tp_c_")
		if tpid_cache and tpid_cache.targets then
			local tgts = tpid_cache.targets
			print("[aiTalentTactics] _______turn_procs CACHED TARGETS for talent", t.id, "selffire=", tpid_cache.selffire,"friendlyfire=", tpid_cache.friendlyfire)
			local last_act, i, act
			repeat
				if tgts.__CLASSNAME then
					act, last_act = tgts, true
				else
					i, act = next(tgts, i)
					if not act then break end
				end
				print("\t", i, act, "at", act.x, act.y, act.uid, act.name)
			until last_act or not i
		end
		print("[aiTalentTactics] _**_ai_state CACHED TARGET TACTICAL WEIGHTS for talent", t.id) table.print(tac_cache[t.id], "\t_ais_twc_")
	end

	 -- If possible, get/reconstruct TACTIC WEIGHTs from turn_procs cached data (for consistent inputs)
	if tpid_cache and not reset_cache then
		-- The base and implicit tactics are stored separately so that the final tactics table can be reconstructed
		-- This allows the cached data to be used for different values of weight_mod (if an instant action changes resource status, for example)

		-- Possibly get the tactics from cached data (if present and inputs are consistent)
		if tpid_cache.tactics and tpid_cache.tactic == cache_tactic and (tpid_cache.target_list == target_list or table.equivalence(tpid_cache.target_list, target_list)) then

			if log_detail > 3 then print("[aiTalentTactics] ***using cached TACTICS DATA ***", t.id, "tactic=", tactic, "cached tactic=", tpid_cache.tactic, "tpid_cache.wt_mod=", tpid_cache.wt_mod, "wt_mod=", wt_mod) end

			if tpid_cache.wt_mod == wt_mod then -- exact same inputs, use last computed tactics directly
				tactics, has_tacs = tpid_cache.tactics, tpid_cache.has_tacs
				weight_mod = 1 -- tactics don't need any adjustment
				if log_detail >= 2 then print("[aiTalentTactics] ***TACTICS TABLE (RETRIEVED from cache)***", t.id) table.print(tactics, "\t_ctr_") end
			else -- different wt_mod, reconstruct last computed tactics from last base_tacs, implicit_tacs
				tactical, tactics = tpid_cache.tactical, table.clone(tpid_cache.base_tacs)
				has_tacs, implicit_tacs = tpid_cache.has_tacs, tpid_cache.implicit_tacs
				weight_mod = (wt_mod and wt_mod*(is_active and -1 or 1) or tpid_cache.calc_weight_mod or 1)
				if log_detail > 3 then print("\tcached base_tacs:") table.print(tactics, "_*cbt_") end
				if weight_mod ~= 1 then -- adjust cached base tactics (once) for wt_mod
					for tact, val in pairs(tactics) do
						tactics[tact] = val*weight_mod
					end
				end
				weight_mod = 1
				if implicit_tacs then -- add any implicit tactics
					if log_detail > 3 then print("\tcached implicit_tacs:") table.print(implicit_tacs, "_*cit_") end
					local iwt_mod = (wt_mod or 1)*(is_active and -1 or 1)
					for tact, val in pairs(implicit_tacs) do
						tactics[tact] = (tactics[tact] or 0) + val*iwt_mod
					end
					implicit_tacs = nil -- ensures it's not added again at end
				end
				if log_detail >= 2 then print("[aiTalentTactics] ***TACTICS TABLE (RECONSTRUCTED from cache)***", t.id) table.print(tactics, "\t_ctR_") end

			end
			cache_tactics = tactics -- for possible later comparison with force_cache_test
		end
	end
	if force_cache_test or not tactics then -- perform tactics computation (no cache)
		--FIRST, process the talent tactical info (tactical table or function)
		if type(tactic) == "function" then tactic = tactic(self, t, aitarget) end
		if type(tactic) == "table" then -- use the tactical table supplied as an argument
			tactical = tactic
			tactic = nil no_implicit = true
		else -- or get the tactical table from the talent
			tactical = t.tactical
			if self.ai_state._imp_tactical and t.tactical_imp then --(DEBUGGING transitional, try to get the "improved" tactical table for full tactical table support)
			if log_detail >= 2 then print("[aiTalentTactics] *** retrieving talent IMPROVED TACTICAL information ***") end
				tactical = t.tactical_imp
			end --(DEBUGGING transitional)
			if type(tactical) == "function" then tactical = tactical(self, t, aitarget) end
		end
		if log_detail >= 2 then print("[aiTalentTactics]__ using talent tactical table for", t.id) table.print(tactical, "\t___") end
		if not tactical then return false end

		if not tpid_cache then -- set up new turn_procs cache
			if log_detail > 2 then print("[aiTalentTactics] *** creating new turn_procs CACHE for talent", t.id) end
			tpid_cache = {} tp_cache[t.id] = tpid_cache
		end

		-- reset if the (possibly talent-specific) cache duration has expired or the offensive hash has changed
		cache_turns = tactical.__wt_cache_turns or cache_turns
		reset_cache = reset_cache or game.turn - tac_cache[t.id]._computed >= cache_turns or tac_cache[t.id]._OHash ~= self.aiOHash

		-- consider marking the weight cache (turn_procs?) table for no save self._no_save_fields (to avoid possible collisions of UID's in _tact_wt_cache[t.id][act.uid]

		if reset_cache then
			if log_detail > 2 then print("[aiTalentTactics] *** creating new target WEIGHT CACHE for talent", t.id, "cache validity(turns):", cache_turns, "aiOHash:", self.aiOHash, "vs (cache):", tac_cache[t.id] and tac_cache[t.id]._OHash) end
			tac_cache[t.id] = {_computed=game.turn, tactic=cache_tactic, _OHash=self.aiOHash}
		end
		tid_cache = tac_cache[t.id]

		tactics, weight_mod, has_tacs, implicit_tacs = {}, 1, false, nil -- initialize output data
		local selffire, friendlyfire
		-- SECOND, get the target(s) for the talent
		if not targets then
			if tpid_cache.targets and not force_cache_test then -- retrieve targets, selffire, friendlyfire, tg from cache
				targets, selffire, friendlyfire, tg = tpid_cache.targets, tpid_cache.selffire, tpid_cache.friendlyfire, tpid_cache.tg
				if log_detail >= 2 then print("[aiTalentTactics] \t targets from turn_procs cache for", t.id, "SF=", selffire, "FF=", friendlyfire, "targets=", targets.__CLASSNAME and targets.name or targets, "tg=", tg) end
			else -- generate targets, selffire, friendlyfire
				local SF, FF
				if log_detail >= 2 then print("[aiTalentTactics] extracting targets, selffire, friendlyfire from tg:", tg or "(from talent)") if tg then table.print(tg, "\t_tg_") end end
				targets, SF, FF, tg = self:aiTalentTargets(t, aitarget, tg, true)
				selffire, friendlyfire = SF/100, FF/100
			end
		else -- use specified target list but compute friendlyfire and selffire from targeting table
			if self:attr("nullify_all_friendlyfire") and not t.ignore_nullify_all_friendlyfire then 
				selffire, friendlyfire = 0, 0
			else
				tg = tg or self:getTalentTarget(t)
				if tg then
					if log_detail >= 2 then print("[aiTalentTactics] extracting selffire, friendlyfire from", tg) table.print(tg, "\t_tg_") end
					local typ = engine.Target:getType(tg)
					selffire = typ.selffire and (type(typ.selffire) == "number" and typ.selffire/100 or 1) or 0
					friendlyfire = typ.friendlyfire and (type(typ.friendlyfire) == "number" and typ.friendlyfire/100 or 1) or 0
				else selffire, friendlyfire = 1, 1
				end
			end
		end

		if not tactical._no_tp_cache then -- update tp_cache values, including inputs for later comparison
			if log_detail > 2 then print("[aiTalentTactics] *** updating turn_procs CACHE for talent", t.id) end
			tp_cache_tactics = true -- trigger pushing computed tactical info to turn_procs
			tpid_cache.target_list, tpid_cache.tactic, tpid_cache.wt_mod = target_list, cache_tactic, wt_mod
			tpid_cache.tactics, tpid_cache.tactical = tactics, tactical
			tpid_cache.targets, tpid_cache.selffire, tpid_cache.friendlyfire, tpid_cache.tg = targets, selffire, friendlyfire, tg
		end

		-- compassion only affects harmful tactics (self.AI_TACTICS[tact] < 0, e.g. attack) used against self and allies
		local self_compassion = (self.ai_state.self_compassion == false and 0) or self.ai_state.self_compassion or 5
		local ally_compassion = (self.ai_state.ally_compassion == false and 0) or self.ai_state.ally_compassion or 1		
		if log_detail >= 2 then --summarize the list of targets for the talent
			print(("[aiTalentTactics]\ttargets for %s: RT:%s(hostile:%s%s) TG:%s SF(x%s)=%s, FF(x%s)=%s"):format(t.id, requires_target, hostile_target, t.onAIGetTarget and ",onAIGetTarget" or "", tg, self_compassion, selffire, ally_compassion, friendlyfire))
			if targets.__CLASSNAME then
				print("\t____", t.id, "may hit", targets.uid, targets.name, "at", targets.x, targets.y)
			else
				for i, tgt in ipairs(targets) do
					print("\t____", t.id, "may hit", tgt.uid, tgt.name, "at", tgt.x, tgt.y)
				end
			end	
		end

		-- THIRD, evaluate each TACTIC using the list of targets
		local tact, val, all_tactics
		local self_tactics
		-- don't cache weights if caching is disabled or SELF is the only target
		cache_wt_values = targets ~= self and cache_turns > 0

		repeat -- FOR EACH TACTIC
			tact, val = next(tactical, tact)
			if not val then
				if self_tactics then -- compute self tactics last, don't cache tactical weights (recompute each time)
					if log_detail >= 2 then print("[aiTalentTactics] begin processing self_tactics:", self_tactics, "\t") table.print(self_tactics, "\t_st_") end
					tactical, self_tactics, cache_wt_values = self_tactics, nil, false
					requires_target, hostile_target = true, false
					targets, selffire, friendlyfire = self, 1, 1
					tact, val = next(tactical)
				end
				if not val then break end
			end
			
			local benefit = self.AI_TACTICS[tact]
			if tact == "self" then -- set up tactics applied only to self for last loop iteration
				if type(val) == "function" then self_tactics = val(self, t, aitarget) else self_tactics = val end
			elseif benefit then -- evaluate the tactic
				local weighted_sum = 0 -- total weight for this tactic
				-- initialize the tactic cache for this tactic if needed
				if cache_wt_values and not tid_cache[tact] then tid_cache[tact] = {} end

				-- note: the "heal" tactic does not take healing_factor of the target into account (can affect either aitarget or self)
				--== TACTIC INTERPRETATION ==-- use target parameters and current target to interpret tactical values
				local f_mult, s_mult, a_mult = 1, 1, 1 -- reaction weights for foes, self, allies
				if tact ~= "special" then
					if self:attr("encased_in_ice") and (tact == "attack" or tact == "attackarea") then
						--attack the iceblock
						f_mult, s_mult, a_mult = 0, 1, 0
					elseif requires_target then -- talent explicitly targeted: correct target is assumed
						--all tactics (positive values) are useful (for the talent user) against ai_target
						-- compassion only affects harmful tactics against friendly targets (to avoid over-stacking of beneficial tactical values)
						if hostile_target then -- talent targets foes
							if benefit < 0 then -- harmful tactic used on foes (e.g. attack) penalized for hitting allies (compassion applied)
								f_mult, s_mult, a_mult = -1, self_compassion, ally_compassion
							else -- beneficial tactic (e.g. heal) used on foes beneficial (for self) against all targets
								f_mult, s_mult, a_mult = 1, 1, 1
							end
						else -- talent targets allies (no compassion effects)
							if benefit < 0 then -- harmful tactic (e.g. attack) used on allies, penalized for hitting foes (this is a little weird but follows the rule)
								f_mult, s_mult, a_mult = 1, -1, -1
							else -- beneficial tactic (e.g. heal) used on allies, penalized for hitting foes
								f_mult, s_mult, a_mult = -1, 1, 1
							end
						end
					else -- talent may be used without a target (applied to self if there are no targets)
						if tg then -- talent has targeting parameters; intended to affect others
							if benefit < 0 then -- harmful tactic penalized for hitting allies (compassion applied)
								f_mult, s_mult, a_mult = -1, self_compassion, ally_compassion
							else -- beneficial tactic (e.g. heal) penalized for hitting foes
								f_mult, s_mult, a_mult = -1, 1, 1
							end
						else -- no targeting parameters, talent assumed to affect the user (no adjustments)
							if benefit < 0 then -- harmful tactic useful against self (like escape) penalized for hitting allies (no compassion effects)
								f_mult, s_mult, a_mult = 1, -1, -1
							else -- beneficial tactic useful against self (like heal) penalized for hitting foes
								f_mult, s_mult, a_mult = -1, 1, 1
							end
						end
					end
				end
				if log_detail >= 2 then print(("[aiTalentTactics] computing tactic %s(%s), val=%s, RT:%s, HT:%s, reaction weights (f=%s, s=%s, a=%s):"):format(tact, benefit, val, requires_target, hostile_target, f_mult, s_mult, a_mult)) end
				local last_act = false
				local i, act--, use_cache
				repeat -- FOR EACH TARGET: retrieve/compute the effectiveness of the tactic for each target
					if targets.__CLASSNAME then
						act, last_act = targets, true
					else
						i, act = next(targets, i)
						if not act then break end
					end
					-- Note: always recalculate weights against self or for the "cure" tactic
					local tgt_weight = cache_wt_values and act ~= self and tact ~= "cure" and tid_cache[tact] and tid_cache[tact][act]
--					if force_cache_test and log_detail > 2 then print("\t__cached weight for tactic:", tact, "vs", act.uid, act.name, "==", tid_cache[tact] and tid_cache[tact][act]) end

					-- Discard the cached tactic weight value for the actor if its defensive hash value has changed
					if tgt_weight and tid_cache[tact][act.uid] ~= act.aiDHash then
						if log_detail > 3 then print("\t__DEFENSIVE HASH reset for tactic:", tact, act.aiDHash, "vs (cached DHash)", tid_cache[tact][act.uid], "for aitarget:", act.uid, act.name) end
						tgt_weight = nil
					end

					local cache_tgt_weight = tgt_weight -- save cached value for possible later check
					if force_cache_test then -- force recomputation of tactical weights
						if log_detail > 2 then print("\t__cached weight for tactic:", tact, "vs", act.uid, act.name, "==", tid_cache[tact] and tid_cache[tact][act]) end
						tgt_weight = nil
					end
					
					if not tgt_weight then -- compute the tactic weight value for this target
						local val = val
						if type(val) == "function" then -- evaluate a function
							if log_detail > 2 then print("\t resolving functional tactic value", val) end
							val = val(self, t, act) or 0
							if log_detail > 2 then print("\t tactic value:", val) table.print(val, "\t\t") end
						end

						local all_vals, val_type, val_wt = false
						tgt_weight = 0
						local count = 10 -- maximum # tactics
						repeat -- FOR EACH WEIGHT parameter: compute the tactical weight for this target
							local weight = 0
							count = count - 1
							if type(val) ~= "table" then -- numerical or functional
								val_type, val_wt = val, val
								all_vals = true
							else
								val_type, val_wt = next(val, val_type) if not val_wt then break end
							end
							if act == self and self:attr("encased_in_ice") and (tact == "attack" or tact == "attackarea") then
								weight = s_mult * math.abs(benefit) -- Frozen status ignores selffire and allows self-fire
							elseif act == self then -- hit self
								weight = selffire*friendlyfire*s_mult*benefit -- matches actor:project
							elseif self:reactionToward(act) >= 0 then -- hit ally
								weight = friendlyfire*a_mult*benefit
							else -- hit foe
								weight = f_mult*benefit
							end
							if log_detail > 2 then print("[aiTalentTactics] tactic:", tact, "val_type =", val_type, "val_wt=", val_wt, "weight", weight, "against", act and act.uid, act and act.name) end
							if weight ~= 0 then
								local effect_type, effect_wt = val_type, val_wt
								local res = 0
								local _, status_chance
	
								-- evaluate function for effect type (update DamageType and weight (wt_adj))
								-- handle predefined "weapon", "archery", "offhand" functions
								local special = type(effect_type) == "function" and effect_type or self.aiSubstDamtypes[effect_type]
								if special then
									if log_detail > 2 then print("\t resolving special effect_type:", effect_type) end
									local wt_adj
									effect_type, wt_adj = special(self, t, act)
									if log_detail > 2 then print("\t special effect_type returned:", effect_type, wt_adj) end
									weight = weight*(wt_adj or 1)
								end
								if type(effect_type) == "number" then -- numerical weights assume no resistances
									effect_wt = effect_type
								else -- evaluate table (adjust for DamageType resistance and status immunities)
									-- evaluate function for effect weight
									if type(effect_wt) == "function" then effect_wt = effect_wt(self, t, act) or 0 end
									
									-- Note: this does not handle the "all_damage_convert_percent" attribute
									--if src:attr("all_damage_convert") and src:attr("all_damage_convert_percent") and src.all_damage_convert ~= type
									
									if DamageType[effect_type] then -- calculate effective DamageType modifiers (increase, resistance, penetration, and affinity)
										local inc, pen = self:combatGetDamageIncrease(effect_type), util.bound(self:combatGetResistPen(effect_type), 0, 100)
										weight = weight*(1 + inc/100)
										res = act:combatGetResist(effect_type)
										if log_detail > 2 then print("\t\t_DamageType Modifiers:", effect_type, "inc, pen, res, aff=", inc, pen, res, act:combatGetAffinity(effect_type)) end
										res = util.bound(res > 0 and res * (100 - pen) / 100 or res, -100, 100) + act:combatGetAffinity(effect_type)
									else -- calculate status immunity
										_, status_chance = act:canBe(effect_type) --Status immunity
										if log_detail > 2 then print("\t\t--- status_chance", effect_type, status_chance) end
										res = 100 - status_chance or 0
									end
									if type(effect_wt) == "table" then -- sum the list of statuses and weights that can affect the actor
										local e_wt = 0
										for status, wt in pairs(effect_wt) do
											_, status_chance = act:canBe(status) --Status immunity
											e_wt = e_wt + wt*(status_chance or 0)/100
											if log_detail > 3 then print("\t\t__status_chance", effect_type, status, status_chance) end
										end
										effect_wt = e_wt
--										if log_detail > 2 then print("\t__status effect weight sum", effect_type, "against", act.uid, act.name, "res=", res, "raw wt:", effect_wt) end
									end
									if log_detail > 2 then print("\t__raw effect_wt ", effect_type, "against", act.uid, act.name, "res=", res, "raw wt:", effect_wt) end
									effect_wt = effect_wt*(100 - res) / 100 -- apply the effective resistance
								end
								effect_wt = effect_wt*weight
								if log_detail > 2 then print("\t__mod effect_wt ", effect_type, "against", act.uid, act.name, "weight=", weight, "res=", res, "mod wt:", effect_wt) end
								-- update totals
								tgt_weight = tgt_weight + effect_wt
							end
						until all_vals or not val_type or count <= 0 -- end target weight loop
						if log_detail > 2 then print("\t__total effect weight tactic:", tact, "actor:", act.uid, act.name, "==", tgt_weight) end
						if cache_wt_values and act ~= self then -- cache the tactical weight value
							-- compare to the previously cached weight value if instructed
							if force_cache_test and cache_tgt_weight then
								local color, msg if act == aitarget then color, msg = "#YELLOW#", "MAIN TARGET" else color, msg = "#ORANGE#", "off target" end
								if math.abs(tgt_weight - cache_tgt_weight) > 1e-6 then -- cache mismatch
									print(("\t***TACTICAL WEIGHT CACHE MISMATCH: [%d]%s %s (%s) on [%d]%s{%s}: %s vs %s(cache)"):format(self.uid, self.name, t.id, tact, act.uid, act.name, msg, tgt_weight, cache_tgt_weight))
									if log_detail > 1.4 and config.settings.cheat then game.log("_[%d]%s %s%s tactical weight CACHE MISMATCH (%s) vs %s[%d]{%s}: %s vs %s(cache)", self.uid, self.name, color, t.id, tact, act.name, act.uid, msg, tgt_weight, cache_tgt_weight) end
								elseif log_detail > 3 then
									print(("\t***TACTICAL WEIGHT CACHE MATCHES: [%d]%s %s (%s) on [%d]%s{%s}: %s vs %s(cache)"):format(self.uid, self.name, t.id, tact, act.uid, act.name, msg, tgt_weight, cache_tgt_weight))
								end
							end
							tid_cache[tact][act] = tgt_weight
							tid_cache[tact][act.uid] = act.aiDHash
							if log_detail > 3 then print("\t__updated cached weight for tactic:", tact, "vs", act.uid, act.name, "==", tid_cache[tact][act]) end
						end
					end -- end compute target weight branch
					weighted_sum = weighted_sum + tgt_weight
				until last_act or not i -- end target loop				
				if log_detail > 2 then print(("[aiTalentTactics] --- total tactical weight: %s=%s(%s),{RT=%s, tgt=%s (aitarget:%s, %s)}"):format(tact, weighted_sum, benefit, requires_target, t.target, aitarget and aitarget.name, hostile_target and "hostile")) end
				-- add to the tactic value
				if not tactics[tact] then
					tactics[tact] = 0
					has_tacs = true
				end
				tactics[tact] = tactics[tact] + weighted_sum
			elseif tact == nil then
				print("[aiTalentTactics]", t.id, "disregarding UNDEFINED TACTIC:", tact)
			end
		until all_tactics -- end tactic loop

		--Note: implicit resource tactics for normal resource costs could be generated here.
		--This would allow the AI to consider resource costs when choosing talents and try to conserve depleted resources, etc.
	
		--== SUSTAINED TALENTS ==-- (special handling)
		-- generate implicit resource tactics and modify weight (if the talent drains resources)
		-- negate tactic weights for active talents and subtract talents to deactivate (sustain_slots)
		if t.mode == "sustained" then
			-- if the talent drains resources (table from initialization), add resource tactics to the tactics table
			local drain_time = 100 -- (estimated) turns the talent can be kept up before a resource runs out
			if t._may_drain_resources and not no_implicit then
				implicit_tacs = {}
				if log_detail > 2 then print("===evaluating resource draining sustained talent", t.id, is_active and "(active)" or "(inactive)") end
				local drains = {} -- drained resources
				local drain, tact, res_def
				local recover_rate -- fraction of std pool recovered/turn if drain rate is reversed
				local std_pool -- pool size against which to weigh gains, losses, and regeneration
				for res, i in pairs(t._may_drain_resources) do
					res_def = ActorResource.resources_def[res]
					drain = util.getval(t[res_def.drain_prop], self, t)
					if drain and drain ~= 0 then -- drains/replenishes resource
						drains[res] = drain
					end
				end
				--print("--- sustained talent drains resources:") table.print(drains, "====")
				for res, drain in pairs(drains) do
					if not tactics[res] then -- generate a tactic for the resource if it's not already present
						res_def = ActorResource.resources_def[res]
						local turns = 100 -- depletion time for resource
						 -- resource computations assume the talent is active
						local regen = self[res_def.regen_prop] + (not is_active and drain*(res_def.invert_values and 1 or -1) or 0)
						std_pool = res_def.ai and res_def.ai.tactical and res_def.ai.tactical.default_pool_size or ((res_def.max or 100) - (res_def.min or 0))
						recover_rate = drain/std_pool
						 -- if the resource is being depleted, compute estimated time to depletion, allowing for  estimated use each turn
						if res_def.invert_values then
							if regen >= 0 then
								if self[res_def.maxname] then
									turns = (self[res_def.maxname] - self[res_def.getFunction](self))/(regen + std_pool*self.AI_RESOURCE_USE_EST)
								else
									turns = std_pool/(regen + std_pool*self.AI_RESOURCE_USE_EST)
								end
							end
						else
							if regen <= 0 then
								if self[res_def.minname] then
									turns = (self[res_def.getFunction](self) - self[res_def.minname])/(-regen + std_pool*self.AI_RESOURCE_USE_EST)
								else
									turns = std_pool/(-regen + std_pool*self.AI_RESOURCE_USE_EST)
								end
							end
						end
						drain_time = math.min(drain_time, turns)
						implicit_tacs[res] = -5*recover_rate/(recover_rate + 0.4) -- < 5, = 1 for 10% recovery/turn
						has_tacs = true
						if log_detail > 2 then print("  ===implicit resource tactic:", t.id, "drains", drain, res, "active regen:", regen, "value:", implicit_tacs[res] ,"--depleted in", turns, "turns, recovery:", recover_rate, "/turn") end
					end
				end
			end
			if is_active then
				if aitarget then  -- in combat, reduce weight if the cooldown is > 10 turns, unless the talent cannot be sustained for long
					weight_mod = math.min(weight_mod, (100/math.min(10, drain_time)/util.bound(self:getTalentCooldown(t) or 1, 2, 50))^.5)
				end
				weight_mod = -weight_mod -- negate for active sustains
			else --reduce weight for inactive sustains if they can't be kept up for at least 10 turns
				weight_mod = math.min(weight_mod, math.max(0, drain_time-1)/9)
				--sustain_slots: add the (negative) tactical values for sustain(s) that would be deactivated
				local slots = t.sustain_slots
				if slots and self.sustain_slots then -- inlined from Actor:getReplacedSustains(talent) for speed
					if 'string' == type(slots) then slots = {slots} end
					for _, slot in pairs(slots) do
						local talid = self.sustain_slots[slot]
						if talid and self:isTalentActive(talid) then --calculate tactical table for talent that would be turned off
							local tal = self:getTalentFromId(talid)
							if log_detail >= 2 then print("[aiTalentTactics]", t.id, "**DEACTIVATES**", talid, ":::") end
							deac_tactics = self:aiTalentTactics(tal, aitarget, nil, nil, nil, 1) -- use full weight here (ignore cooldown and drain effects for deactivated talent)
							if log_detail >= 2 then print("[aiTalentTactics]", t.id, "**DEACTIVATION TACTICS**", talid, "tactics:", string.fromTable(deac_tactics)) end
							if deac_tactics then -- add to the tactics table
								table.merge(tactics, deac_tactics, true, nil, nil, true) -- deep merge, add numbers
								has_tacs = true
							end
						end
					end
				end
			end
			if log_detail >= 2 then print("\t--- sustained weight modifier adjustment:", weight_mod, drain_time, "usable_turns, cooldown turns:", self:getTalentCooldown(t), is_active and "active" or "inactive") end
		end -- end sustained branch
		if tp_cache_tactics then -- cache computed tactical info to turn_procs to reconstruct the tactical table on later calls
			tpid_cache.base_tacs, tpid_cache.tactics = table.clone(tactics), tactics -- save base tactics (weight_mod not applied)
			tpid_cache.implicit_tacs = implicit_tacs
			if log_detail > 2 or force_cache_test then
				print("  ***tp_caching base tactical table,", self.uid, self.name, t.id, tpid_cache.base_tacs) table.print(tpid_cache.base_tacs, "\t_bt_")
				if implicit_tacs then print("  ***tp_caching implicit tactics:") table.print(implicit_tacs, "\t_it_") end
			end
			tpid_cache.wt_mod = wt_mod -- cache input wt_mod
			tpid_cache.has_tacs = has_tacs
			tpid_cache.calc_weight_mod = weight_mod -- cache the computed weight_mod
		end
		if wt_mod then weight_mod = weight_mod*wt_mod end
	end -- end tactics computation (no cache) branch
	-- apply the weight modifier (weight_mod, will be negated for active sustains)
	if log_detail >= 2 then print("\t\tis_active=", is_active, "wt_mod=", wt_mod, "weight_mod=", weight_mod) end
	if weight_mod ~= 1 then
		for tact, val in pairs(tactics) do
			tactics[tact] = val*weight_mod
		end
	end
	-- add any implicit tactics (not affected by weight_mod, but negated for active sustains)
	if implicit_tacs then
		--if log_detail >= 2 then print("  ***adding implicit tactics:") table.print(implicit_tacs, "\t_it_") end -- debugging
		local iwt_mod = (wt_mod or 1)*(is_active and -1 or 1)
		for tact, val in pairs(implicit_tacs) do
			tactics[tact] = (tactics[tact] or 0) + val*iwt_mod
		end
	end
	if log_detail >= 2 then print("[aiTalentTactics] ***COMPUTED TACTICS TABLE***", t.id, is_active and "active sustain", "wt_mod=", wt_mod, "weight_mod=", weight_mod, "tactics:") table.print(tactics, "\t_ft_") end
	
	-- if requested, compare results to the cache-reconstructed tactical table
	if force_cache_test and cache_tactics and tp_cache_tactics then
		print("[aiTalentTactics] ***previous TACTICS TABLE (from tp cache)***", self.uid, self.name, t.id, "cache_tactics:") table.print(cache_tactics, "\t_octf_")

		if not table.equivalence(tactics, cache_tactics) then
			print("[aiTalentTactics] ***tp TACTICS TABLE CACHE MISMATCH", self.uid, self.name, t.id)
			local ctt = string.fromTable(cache_tactics, nil, nil, nil, nil, true)
			print("____Cached tactics:", ctt)
			local ntt = string.fromTable(tactics, nil, nil, nil, nil, true)
			print("____Computed tactics:", ntt)
			if log_detail > 1.4 and config.settings.cheat then -- debugging
				game.log("_[%d]%s #YELLOW# TACTICAL turn_procs CACHE MISMATCH for %s", self.uid, self.name, t.id)
				game.log("#YELLOW_GREEN#____Cached tactics: %s", ctt)
				game.log("#YELLOW_GREEN#__Computed tactics: %s", ntt)
			end -- debugging end
		else
			print("[aiTalentTactics] ***tp CACHED TACTICS TABLE MATCHES new TACTICS TABLE", self.uid, self.name, t.id)
		end
	end
	
	if has_tacs then
		if type(tactic) == "string" then
			return tactics[tactic] or 0
		else
			return tactics
		end
	end
	return false
end

--- Sets the current AI target
-- @param [type=Entity, optional] target the target to set (assign nil to clear the target)
-- @param [type=table, optional] last_seen data for use by aiSeeTargetPos
-- When targeting a new entity, checks self.on_acquire_target and target.on_targeted
function _M:setTarget(target, last_seen)
	local old_target = self.ai_target.actor
	engine.interface.ActorAI.setTarget(self, target, last_seen)
	if target and target ~= old_target and game.level:hasEntity(target) then
		if target.fireTalentCheck then target:fireTalentCheck("callbackOnTargeted", self) end
	end
end