Major AI Update

All development conversation and discussion takes place here

Moderator: Moderator

Post Reply
Message
Author
Hachem_Muche
Uruivellas
Posts: 744
Joined: Thu Nov 18, 2010 6:42 pm

Major AI Update

#1 Post by Hachem_Muche »

I've been working on a significant update to ToME's AI for some time, and it's getting close to completion.

Below is a summary of the changes in progress. (The current git MR is here: https://git.net-core.org/tome/t-engine4 ... quests/463 )
There's a lot of material here, but everything works pretty much the same way it always has (or was intended to).

The best way to digest this is probably to first skim over the changes summary below. (Most talent developers will not need to be very familiar with the more technical changes.)
Then, to understand how the new tactical AI works, review the following posts, containing the lua code documentation covering the tactical AI and tactical tables, together, which contain a number of examples.

Testing Instructions:
This development contains some testing code and is intended to be run in ToME's development mode. It contains the following soft switches which can be changed “on the fly” in the lua console as required:

config.settings.ai_transition:
Set true to automatically call the new talented and tactical AIs in place of their older (but updated) counterparts. This should be set true for the current testing, but can be turned off to (mostly) revert to old AI behavior.

config.settings.tactical_cache_test (Performance penalty):
Set true to force cache testing of new tactical tables. Should not be needed at this point.

config.settings.log_detail_ai (Performance penalty):
Set from 0 to 4 to control the level of detail output to the log file by various AIs and associated functions. This should probably be set to at least level 1 during testing to provide general information on what the AI is doing. Level 4 is VERY verbose (but very useful for debugging tactical tables, etc.)

The normal operating mode will be with config.settings{ai_transition=true, tactical_cache_test=false, log_detail_ai=0}.

The main things to look out for when testing are:
1) Lua errors (obviously)
2) Any talents that completely confound the AI (so that it keeps trying and failing to use it over and over, for example)
3) Any situations where the new tactical AI does something obviously stupid (an action that could NEVER be the right one)

Comments are welcome!

SUMMARY OF CHANGES:

Code: Select all

This update has 3 main parts:
A)	Upgrading AI primitives (“infrastructure”) in the engine to make designing new and more capable AIs easier.
B)	Rewriting the Talented and Tactical AIs, plus adding some supporting AIs, so that NPCs in ToME using them behave more intelligently.
C)	Correcting and updating tactical tables and associated code for a number of (but not all) talents that are currently not being used effectively by NPCs.

HIGHLIGHTS:
Major rewrite of many AI definitions and functions, including new functions to make writing new AIs simpler.
Completely new talented and tactical AIs (for ToME) that allow NPCs to act without contact with the player, actively manage all standard resources, and more consistently comply with their AI specifications.  This includes provisions for all NPCs to avoid drowning/grid damage, better coordinate movement and talent use, and invoke other AIs.
NPCs can both activate and deactivate all sustain talents intelligently (particularly for the tactical AI) with or without player contact, according to the tactical situation.
The new AIs are designed to work concurrently with their existing counterparts to allow for them to be phased in.

Main Game Play Effects (for NPCs using the new AIs): 
NPCs do not require contact with the player to act.
NPCs will actively manage all standard resources, including their health.
NPCs can both activate and deactivate sustained talents as appropriate.
NPCs will move away from harmful (damaging, suffocating) terrain, and will do a better job at maintaining appropriate distance from the player while in combat (new tactical AI).
If using a talent fails, the NPC will immediately attempt to use another (next best for the tactical AI) talent.

GAME LOG DETAIL CONTROL:
The amount of detail printed in the game log by most engine/ToME ai functions can be controlled by setting the variable:

	config.settings.log_level_ai = X

Where X is a number from 0 (no extra output) to 4 (very verbose output) specifiying how much detail to output to the game log.

NEW/UPDATED AI’s included:
“dumb_talented” (engine) – updated to add support for t.onAIGetTarget.
“improved_talented” (engine) - Simplified version of “dumb_talented” using the new talent filter system. Designed as a drop-in replacement for the dumb_talented AI using the new AI functions.
“improved_talented_simple” (ToME) – designed as an overload for the dumb_talented_simple AI
“improved_tactical” (ToME) – designed as a drop-in replacement for the tactical AI (details below)
“maintenance” (ToME) – manages health and resources, triggering other appropriate talents and AIs, (mostly for simple AIs out of combat)
“move_safe_grid” (ToME) – moves from harmful to safe terrain
“flee_dmap_keep_los” (ToME) – migrated from ai.tactical.lua, flees from current target while maintaining LOS to it
“target_simple” (engine) – will only maintain targeting on hostiles
"target_simple" (ToME) – has an increased chance to clear friendly targets, always clears self
“tactical” --The existing tactical AI has been partially updated to be compatible with most of the new tactical table functionality, with minimal changes.  (It lacks most of the additional features of the new tactical AI in order to work as before as much as possible.)  Better log file output.

NEW TACTICAL AI:
This is an almost complete rewrite of the current tactical AI that is compatible with existing NPC specifications and data.
Interprets existing tactical tables as before, but supports additional information allowed in the new tactical table format.
The tactical AI weighs all tactical table parameters (not just the best one) when evaluating talents, making it much better at picking the correct talent at the correct time.
Full support for both positive and negative tactical values – this allows the AI to intelligently turn on and off sustained talents, including those with sustain_slots or that drain resources.  (For example, an NPC can switch between hymns or vile poison enhancements as the tactical situation develops.)
Automatically manages all standard resources, with support for resource-specific wants.
Does a better job at maintaining an advantageous distance to the target.

(Un-tested) The tactical AI should run significantly faster than before for most NPCs.  The new AI is more efficient when evaluating and performing actions, but does more.

Extensive documentation for the changes, including detailed instructions/tutorial for how to construct tactical tables.
Defined key parameters for supported TACTICs in mod.class.interface.ActorAI.  This allows new TACTICs to be added easily, including by addons.

EXTENDED TACTICAL TABLE functionality:
Added detailed documentation on how to construct tactical tables and how they are interpreted by the (new) tactical AI.
No changes are required to current tactical tables for them to be interpreted by the new AI functions the same way they were interpreted before.  Note, however, that many of the existing tactical tables have significant errors that prevent NPCs from using talents appropriately.  (A number of these have been corrected as part of this development.)

Changes:
Tactical functions take the actor affected rather than the NPC's ai target as an argument in order to be consistent with the way DamageType and status resistances are interpreted.

There is an additional (lowest) level of table parsing for status immunities to allow both DamageType resistance and status effects to be accounted for.

For example, Apply Poison’s NATURE-based damage is added to the tactical table as:

tactical = { ATTACK = {NATURE = {poison = 2}}}

which takes both the target’s poison immunity and NATURE resistance into account.

A sub-table  SELF = {…} can be specified for tactics that apply only to the talent user.  This helps specify tactics for talents that have different effects on the user and on their target(s).

For example, the updated tactical table for lightning runes is now:

tactical = { ATTACK = { LIGHTNING = 1 }, SELF = {defend = 1} }

reflecting the fact that it provides defense to the user (but not the target).

When applying DamageTypes, source damage bonuses and target affinity are accounted for in addition to resistances and penetration.

Alternate tactical tables (t.tactical_imp) are included for some talents during the transitional period.)

AI talent use changes:
Completed support (This was only partially supported before.) for the talent field t.onAIGetTarget for all engine AIs and support functions.  ActorAI:getTarget(typ).

Separated ai pre-use talent checks from actor:preUseTalent into new function (ActorAI:aiPreUseTalent) at the engine level.   This is needed to allow sustains to be turned on and off properly (to allow ActorTalents:forceUseTalent to turn off sustains (with buff removal effects, for example), when the AI would otherwise NOT turn them off.
For ToME, this permits the function talent.on_pre_use_ai to control both activating and deactivating sustained talents.

SUMMARY of FUNCTIONS:
New Engine Functions:
ActorTalents:filterTalent(t, filter) - checks a talent against a filter
ActorAI:aiTalentTargets(t, aitarget, tg, all_targets, ax, ay) - Generate a list of target entities (Actors) a talent MAY affect if used (This provides engine support for the dummy projection performed in the current tactical AI.)
ActorAI:aiGetAvailableTalents(aitarget, t_filter, t_list) – generates a list of talents available for use
aiPreUseTalent(t, silent, fake) – (stub) determines if the AI should use a talent (separated from preUseTalent to allow sustains to be correctly turned on and off)
ActorAI:getTarget(typ) – supports talent.onAIGetTarget, talents automatically self-targeted, and supports the grid_params argument to locate grids if the targeting table requires it.
ActorAI:getTargetGrid(tg, params) – acquires grids via projection, choosing one to move to based on parameters in the targeting table -making targeting of movement talents for NPCs much simpler.

Updated Engine Functions:
ActorAI:setTarget will automatically check on_acquire_target and on_targeted functions when a new target is set.
ActorAI:runAI will return nil, generating a log message, rather than crashing with an undefined ai

New ToME functions:
ActorAI.aiParseTalent(t) – parses a talent definition, setting some variables for AI use – making talent analysis faster for the AI
ActorAI.AI_InitializeData() – parses all talent definitions, initializes resource tactics, and logs a summary of all tactics detected in talent definitions
ActorAI:aiPreUseTalent(t, silent, fake) – Determines if the AI should use the talent.  Checks t.on_pre_use_ai (applies to both activation and deactivation of sustained talents) and caches its results in the self.turn_procs table.
ActorAI:aiCheckSustainedTalent(t) – determines if a sustained talent should be toggled (Allows simple AIs to turn on and off sustained talents correctly.  This is primarily concerned with resource drains.)
ActorAI:aiGetResourceTalent(res_def, t_filter,  t_list) – finds a talent to replenish a resource
ActorAI:aiResourceAction(res_def, t_filter, t_list) – finds an action (talent or AI) to replenish a resource
ActorAI:aiHealingAction(aitarget, filter, t_list) – finds an action (talent or AI) to heal self
ActorAI:aiGetGridDamage(gx, gy) – computes expected grid damage and air loss for the actor.  Updated some grid definitions to allow this function to detect the damage.
ActorAI:aiGridHazard(gx, gy, dam_wt, air_wt) – rates the overall hazard level for grid.  May be overloaded for some Actors to allow for special map navigation.
ActorAI:aiFindSafeGrid(radius, want, dist_weight, ignore_los, grid_val) – finds the a nearby grid to escape grid damage and suffocation.
ActorAI:aiCanFleeDmapKeepLos() – moved from tactical ai for general use
ActorAI:aiTacticEffectValues(targets, effect_type, effect_wt, talent, selffire, friendlyfire) – computes the tactical value of specific tactic weight argument vs a list of actors – for concurrent compatibility with the existing tactical AI
ActorAI:aiTalentTactics(t, aitarget, targets, tactic, tg, wt_mod) – fully resolves the tactical table for a talent (including finding targets as needed) – used by the new tactical AI to evaluate talents and by some simple AIs to use tactical tables when choosing talents
Combat:combatGetAffinity(type) – returns damage affinity
Talents.aiLowerTacticals(tactical) – function to convert TACTIC labels to lower case in tactical tables
Player:restStep() - overload of engine.interface.PlayerRest:restStep() that triggers the new callback "callbackOnWait"

Updated ToME Functions:
NPC:act() – NPCs will perform resting activity if they take no other action during a turn.  This uses the new callback "callbackOnWait"
NPC:getTarget(typ) – simplified to reflect the changes to ActorAI:getTarget
NPC:seen_by(who) – NPCs will not pass friendly targets to each other unless ai_state._pass_friendly_target is set
NPC:aiCanPass(x, y) – removed (uses engine version), shove pressure folded into ActorAI:moveDirection


MISC RELATED CHANGES:
The level of log file detail output by various AI functions can be specified by setting config.settings.log_detail_ai to a value from 0 (minimal output) to 4 (very verbose output).

Engine:
ActorAI:aiSeeTargetPos(target) – added extra checks to the estimated location to exclude it if another actor is seen there and that the target can actually move there – this significantly reduces the chance to target friends or self
ActorLife:takeHit(value, src, death_note) – death message is not displayed if src is undefined
utils: table.to_strings = function(src, fmt) – converts a table to table of formatted strings, very useful for various debugging outputs
utils: table.equivalence(t1, t2, recurse) – checks if two tables are equivalent, i.e. they are identical or contain the same values assigned to the same keys, can recursively track all of the keys to the first difference found

ToME:
ActorAI:moveDirection(x, y, force) – moved from NPC.lua, incorporates shove algorithm code, fixing a bug that causes shove pressure to almost always fail
Actor:preUseTalent(ab, silent, fake) – updates any missed AI parsing of talents as needed when tested
Resource definitions updated with new AI parameters (equilibrium, paradox, psi, air)
Actor:regenLife(fake) --- returns the life, psi regenerated, updated to take a fake parameter to calculate the amount of life and psi that will be regenerated without applying the values.  Eliminated the resting hack for Solipsism: life regeneration is only reduced by the amount of psi regeneration that can be used, none is diverted once psi is replenished.
Updated Actor status immunity functions:
Actor:canBe(what, eid) -- Rewritten to reference a table of status type labels that makes defining new status types (addons) much simpler.  Can take an argument for the effect id being applied, which forces checking of general status immunities (negative_status_immune is always checked), and can be used to force checking of status types contained in the effect subtype table. (i. e. self:canBe(nil, “EFF_DAZED”) will check stun_immune.)
Now returns the random result, percent chance to be affected.  AIs have been updated to use the chance rather than an individual result, so that tactical weights are consistent across multiple calls.
Actor.newStatusType(what, method) – used to define a new status type. e.g. Actor.newStatusType(“annoy”, true) causes self:canBe(“annoy”) to check the self.annoy_immune field.
Actor:canSeeNoCache(actor, def, def_pct) – updated to always return the correct chance to see the target (regardless of success).
Actor:getTalentSpeed(t) – looks for a t.getEnergy function that returns the amount of game energy needed to use the talent (on the basis of 1 = 100% of normal, stacks with talent type speed).
Combat:combatGetResist(type) -- results are properly bounded between -100% and + 100%.
resolvers.talented_ai_tactic updated to better balance ai_tactic for more tactical flexibility.  Fixed an upvalue(!) bug that could cause failure when placing randbosses generated from an earlier save file.


Some ToME TALENT CHANGES:
Note many talent defintions have incorrect tactical tables.  A number of the most complex (for the AI) talents have been updates with new tactical information so that NPCs can use them.

Talents can have .ai_level field to specify the (raw) talent level to use for AI purposes (For prodigies and other 1 point talents).

Summoning Talents – Defined new functions in talents.lua (available to all talent definitions) supporting significant tactical changes which allow NPCs to better pick appropriate summoning talents and place summons.
Poisons – use the updated AI changes to allow NPCs to switch between poison effects in combat.
Psionic Projection talents – tactical tables for discharging the talent now take into account the loss of the aura effect
Meditation – enabled for NPCs who will actively toggle this talent to manage their equilibrium.
Spacetime Tuning – enabled for NPCs which will automatically set an appropriate preferred paradox value.
Suncloak – enabled for NPCs (requires LOS to target)
True Grit – enabled for NPCs (since they can now manage their resources)
Nature Touch – NPCs will search for a target (favoring self) that needs healing


Some ToDo Items:
Update the way the Actor.ai_tactics table is used to allow for sustains/status effects to modify tactical priorities in the tactical AI.  So wild speed could reduce but not eliminate talent use, Unstoppable would prevent running away while active, etc...
Finish enabling summoning talents for NPCs : Detonate, Rage, Phase Summon
Wards: NPCs must match wards to target attack types
Add a method in data.damage_types.lua to translate compound DamageTypes into the base types (that have resistances)
Last edited by Hachem_Muche on Sat Jul 01, 2017 6:12 pm, edited 6 times in total.
Author of the Infinite 500 and PlenumTooltip addons, and the joys of Scaling in ToME.

Hachem_Muche
Uruivellas
Posts: 744
Joined: Thu Nov 18, 2010 6:42 pm

Re: Major AI Update

#2 Post by Hachem_Muche »

The new tactical AI, how it works and the Tactics supported:

(Note that code references refer to labels within the code in mod.ai.improved_tactical.lua, see https://git.net-core.org/tome/t-engine4 ... quests/463)

Code: Select all

IMPROVED TACTICAL AI:
This AI determines when (or if) to use various actions available to an NPC.  It evaluates each action (which can be either using a talent or invoking another AI) according to how well it addresses various needs, where the needs are classified into various predefined TACTICs (described below).  The AI assigns a TACTICAL SCORE (a number representing overall usefulness) to each possible action and then attempts to execute the most useful action based on this value.

In this description, terms in ALL CAPS refer to specific variables; e.g. "SELF" refers to the acting NPC invoking this AI.

For talents, the TACTICAL SCORE is primarily based on its TACTICAL TABLE such as:

		t.tactical = {attack = {LIGHTNING = 2}}

(This talent fulfils the "attack" TACTIC with average effectiveness, modified by the LIGHTNING DamageType.)

Another possible computation for the TACTICAL SCORE might be summarized as:

	TACTICs:			attack		closein		disable		interpretation
	TACTIC WEIGHTs:		1			2			3			the action attacks, disables, and closes with target
	WANTs:   			2			-2.5		1.5			SELF wants to attack and disable, but avoid closing in
	SELF.ai_tactic:		3			1			2			SELF (optionally) favors the attack and disable TACTICs
	---------------------------------------------------
	(Column) Product:	6		+	-5		+	9		==	10	RAW TACTICAL SCORE

The last value is modified to get the FINAL TACTICAL SCORE for comparison with other available actions.
	
METHOD:
Evaluating the TACTICAL SCORE for each talent (or other action) requires 3 steps, not necessarily performed in order:

	1.	Calculate the TACTIC WEIGHTs (a table for each talent/action) for each TACTIC the talent supports.
	This is a complex procedure, performed (for talents) by ActorAI:aiTalentTactics, that uses the tactical parameters for the talent (contained in t.tactical, usually a table).  It takes into account the actors that may be affected by the talent/action and various attributes, including resistances, immunities, other known talents, etc.  (The section "==== TALENT TACTICAL TABLES ==== (with examples)" in mod.class.interface.ActorAI.lua contains a detailed explanation of how to construct tactical tables and how they are interpreted for each talent.)
	
	2.	Calculate the WANT VALUEs (a table for SELF) for all TACTICs.
	This calculation is performed by this AI (see "--== SUPPORTED TACTICS ==--" below).  A WANT VALUE is number reflecting how desirable a TACTIC is to SELF, usually ranging from -10 to +10.  It increases as the TACTIC becomes more useful.  0 represents no tactical value, while +2 is typical for a TACTIC the AI considers useful, and +10 reflects an urgent need, giving a large priority boost to the action.  Negative values correspond to undesirable TACTICs that the AI considers harmful to SELF.

	3.	Calculate the FINAL TACTICAL SCORE (a number) for the talent/action.
	The RAW TACTICAL SCORE is computed as the sum of each TACTIC WEIGHT (for each supported TACTIC) times its corresponding WANT VALUE.  This is then adjusted for other factors, like effective talent level and action speed to get the FINAL TACTICAL SCORE.  Only actions for which this value is > 0.1 are considered worth performing.

The AI will attempt to perform the action with the highest (positive) FINAL TACTICAL SCORE.  SELF.ai_state.tactic is assigned the TACTIC label corresponding the highest TACTIC WEIGHT for the action before it is performed, and can be used as an additional input within talent and AI code where an NPC may need to make choices.

TALENT INPUTS:
In order to use talents, this AI uses certain talent fields, which must be defined appropriately within the root of the talent definition:

-	tactical: tactical parameters (table or function), for evaluation by the aiTalentTactics function, specifying which TACTICs the talent fulfils (Talents without this field will not be used by this AI.)
-	requires_target: must evaluate to true for talents that require a reachable target to use
-	range: <defaults to 1> used to determine if a targeted talent can reach the target and to build a target list
-	radius: <defaults to 0> used to determine if a targeted talent can reach the target and to build a target list
-	target: <optional, defaults to a "bolt" attack> a parameter table for targeted talents
		Used by engine.Target:getType to define how to target the talent (includes information on AOE dimensions, friendly fire parameters, etc.).  This is usually used directly by the talent's action function.
		If it is absent, the talent will automatically target SELF.
-	onAIGetTarget: <optional> a function(self, talent) returning x, y, target called to get the talent target
		(usually used to target something other than SELF's primary target).
-	ai_level: <optional, defaults to raw talent level> a number or function(self, talent)
		the talent level to use when calculating the FINAL TACTICAL SCORE

Targeted talents are those with a defined talent.target field or for which SELF:getTalentRequiresTarget(t) returns true.

ACTOR INPUTS:
NPC AI parameters from mod.class.interface.ActorAI and engine.interface.ActorAI:
SELF.AI_TACTICS holds benefit coefficients, indexed by TACTIC name, representing how beneficial each tactic is (to SELF) when applied to itself or allies, typically +1 (beneficial) or -1 (harmful).
SELF.AI_TACTICAL_TALENT_LEVEL_BONUS (default 0.2) = level adjustment for talents (as raw talent level)
SELF.AI_TACTICAL_AI_ACTION_BONUS (default 0.02) = level adjustment to AI (non-talent) actions when computing the FINAL TACTICAL SCORE
SELF.AI_RESOURCE_LEVEL_TRIGGER = minimum resource level (fraction of maximum) before the AI will consider replenishing a resource

SELF.ai_state parameters:
SELF.ai_state.tactical_random_range = random range applied to the RAW TACTICAL SCORE (default SELF.AI_TACTICAL_RANDOM_RANGE, 0.5) Increasing this makes talent choices more random.
SELF.ai_state.self_compassion = tactic value multiplier when affecting SELF (default 5, for harmful tactics)
SELF.ai_state.ally_compassion = tactic value multiplier when affecting an ally (default 1, for harmful tactics)

SELF.ai_tactic table:
This is a table of multipliers (see mod.resolvers.tactic) for each tactic:

	{TACTIC1 = multiplier1, TACTIC2 = multiplier2, ...} (each tactic is lower case)

The want values for each tactic are multiplied by these values (Undefined multipliers default to 1.) before the final RAW TACTICAL SCORE calculation.  This table may include the .safe_range field, specifying a minimum range that SELF will try to maintain to its target; want.escape (described below) will be increased if the range goes below this value.  (Note: if SELF has no talents that can reach the safe range, it will usually not fight effectively.)

For example:

	SELF.ai_tactic = {disable=2, escape=3, safe_range=4}
	
defines a tactical bias towards disabling the target (2x) and escape (3x, with a further increase if the target is closer than range 4).

ALGORITHM and IMPLEMENTATION:
The main (local) variables used by this AI are want, actions, and avail.  The want table (stored in SELF.ai_state._want) contains the WANT VALUEs for each TACTIC considered:

	want = {TACTIC1 = value1, TACTIC2 = value2, ...}
	
The actions table (stored in SELF.ai_state._actions) contains a list of all available actions (each either a talent or another AI) and the parameters to perform them:

	actions = {{action1 parameters, ...}, {action2 parameters, ...})
	
Each entry in the actions table is a sub-table containing the parameters needed to perform the action.  For talents, these are:

	tid:		talent id
	tacts:		resolved TACTIC WEIGHTs for the talent
	lvl:		raw talent level (or value returned from talent.ai_level, if defined)
	mode:		talent mode ("sustained" or "activated")
	is_active:	status table for active sustained talents
	speed:		relative energy cost to use the talent (SELF:getTalentSpeed(talent), minimum 0.1)
	force_target: <optional> specific target for the talent (for later implementation)
	
while for AI's they are:

	ai:			AI tag (e.g. "move_simple")
	tacts:		resolved TACTIC WEIGHTs for the action
	speed:		relative energy cost to perform the action (minimum 0.1)
	... :		indexed parameters to be passed to the action AI as SELF:runAI(action.ai, unpack(action))
	
The avail table (stored in SELF.ai_state._avail) contains data on each TACTIC supported by the available actions:

	avail = {TACTIC1 = {data1, ...}, TACTIC2 = {data2, ...}, ...}
	
where each datum is a table with the fields:

	num: number of available actions using TACTIC
	best: largest TACTIC WEIGHT for the available actions using TACTIC
	best_action: reference to the table of action parameters for the action with the largest TACTIC WEIGHT
	
Only actions with at least one positive TACTIC WEIGHT update the avail table.
	
PROCEDURE (see --=== EVALUATE TALENTS ===-- below):
For each possible action, a table of TACTIC WEIGHTs is created, representing how effectively it fulfils various TACTICs:

	tactical = {TACTIC WEIGHT1 = value1, TACTIC WEIGHT2 = value2, ...}
	
TACTIC WEIGHTs may be positive or negative, and are not typically bounded, but usually lie within the range [-5, +5].  For talents, they are generated from the talent.tactical field by the SELF:aiTalentTactics function.  The largest TACTIC WEIGHT for a useful action will typically be around +2 in most cases.  An active sustained talent that may be turned off has the sign of its tactic values reversed.

SELF:aiTalentTactics automatically generates a list of potentially affected targets (if required, calling self:aiTalentTargets) and uses the talent's targeting parameters to determine how to apply the talent tactical parameters to each target.  (See the notes section labelled "--BENEFICIAL VS. HARMFUL TACTICS and HOSTILE VS. FRIENDLY TARGETS--" in mod.class.interface.ActorAI:aiTalentTactics for a detailed explanation of how the weights of different targets are determined.)

If the action has at least one positive TACTIC WEIGHT, is considered useful, and an entry is added to the actions table and the avail table is updated.

After the TACTIC WEIGHTs of all available actions have been computed, the RAW TACTICAL SCORE of each action, representing its overall usefulness, is computed as the sum of the products of the matching want and TACTIC WEIGHT fields (an inner product of two vectors, weighted by the SELF.ai_tactic table):

	RAW TACTICAL SCORE = want[matching TACTIC1]*tactical[matching TACTIC1]*(SELF.ai_tactic[matching TACTIC1] or 1
				+ want[matching TACTIC2]*tactical[matching TACTIC2]*(SELF.ai_tactic[matching TACTIC2] or 1)
				+ ...

The action's primary TACTIC is designated according to the largest (positive) contribution to this sum.
Special Note:  The CLOSEIN and ESCAPE TACTICs are mutually exclusive; only the one contributing the most to the RAW TACTICAL SCORE is used.

The FINAL TACTICAL SCORE is computed by adjusting the RAW TACTICAL SCORE for action speed, effective level and a random bonus (used to provide both randomness and to break ties):

	FINAL TACTICAL SCORE = RAW TACTICAL SCORE*level_adjustment*random_range/speed

where:

	speed = action.speed (while in combat) or 1
	level_adjustment (for talents) = 1 + raw talent level*SELF.AI_TACTICAL_TALENT_LEVEL_BONUS
	level_adjustment (for AIs) = 1 + SELF.level*SELF.AI_TACTICAL_AI_ACTION_BONUS
	random_range = 1 + (SELF.ai_state.tactical_random_range or SELF.AI_TACTICAL_RANDOM_RANGE)

The action with the highest FINAL TACTICAL SCORE (> 0.1), is selected to be performed, as long as the WANT VALUE for its primary TACTIC is >= 0.1.

During its processing, the AI gathers some statistics about SELF's talents and the tactical situation:

	fight_data (SELF.ai_state._fight_data, reset whenever there is no target): 
		actions: total number of actions taken in the current fight
		attacks: total number of attacks performed in the current fight

	talent_stats (SELF.ai_state._talent_stats, updated every 100 game turns -- every 10 actions, usually):
		talent_count: number of non-passive talents known
		is_attack: list of talents fulfilling the ATTACK, ATTACKAREA, or ATTACKALL TACTICs
		attack_count: number of talents considered to be attacks
		combat_only: activated, untargeted talents excluded from use out of combat (with some exceptions)
		attack_ranges: attack counts indexed by talent reach (range + radius)
		attack_max_range: longest reach of all attack talents
		attack_desired_range: estimated best range in which at least 50% of attacks can be used
		attacks_in_range, attacks_out_range: number of currently available talents that can reach, not reach the current target (recomputed each time this AI is invoked)
		
This data is used to modify the WANT VALUEs for some TACTICs and to restrict the use of some actions.

--== SUPPORTED TACTICS ==--
A description of what each TACTIC does, including how its corresponding WANT VALUE is calculated as follows.  Notes and code statements related to each TACTIC are labelled below with a comment using the format --== <TACTIC NAME> ==--.

--== ATTACK ==--
description: the action deals damage to one or more targets
typical tactical table entry: {ATTACK = 2} or {ATTACK = {LIGHTNING=2}} or {ATTACK = {weapon=3}}
want: 2 (reduced if SELF has damage reducing attributes: "numbed", "stunned", "dazed", "invisible_damage_penalty")
This is the baseline WANT VALUE, against which all other tactics are compared.
	
--== ATTACKAREA ==--
description: the action deals damage to one or more targets
typical tactical table entry: as ATTACK but usually used for attacks that affect multiple targets
want: same as want.attack
There is currently no difference between this tactic and ATTACK, but it is useful for specifying multiple attack TACTICs within a single talent.

--== ATTACKALL ==--
description: mostly deprecated, handled as ATTACKAREA

--== LIFE ==--
description: an internally used tactic (not used in tactical tables) reflecting SELF's life condition, used to compute other tactics.
want range: [0, +10)
The want value is affected by self_compassion and is computed using the full life range from SELF.die_at to SELF.max_life and assumes one turn of regeneration.  If SELF knows the Solipsism talent, effective life is computed from a weighted average of life and psi.
want vs. %life (self_compassion = 5): 0.00@100%, 0.13@90%, 2.00@59%, 2.85@50%, 4.00@40%, 8.31@10%, 9.98@0%

--== HEAL ==--
description: action heals the target (usually SELF) or (rarely) prevents damage directly
typical tactical table entry: {HEAL = 2} or {HEAL = function(self, t, target) ...}
	(Typically, the function returns a number based on the target. i.e. self:reactionToward(target) > 0)

want range: [0, +10)
The want value is computed like want.life but takes into account SELF.healing_factor and attributes that prevent healing.
want vs. %life (self_compassion = 5, healing_factor = 1): 0.00@100%, 0.13@90%, 2.00@59%, 2.85@50%, 4.00@40%, 8.31@10%, 9.98@0%

--== CURE ==--
description: the action removes detrimental effects
typical tactical table entry: {CURE = 2} or {CURE = function(self, t, target)}
	(Typically, the function returns a number based on how many bad status effects can be removed by the action.)

want range: [0, +10)
The want value uses a diminishing returns formula based on the total duration of all (removable) detrimental effects on SELF.
want vs total detrimental duration: 0@0, 0.24@1, 2.00@10, 3.33@20, 5.55@50, 7.14@100

--== RESOURCES ==--
description: action replenishes the appropriate resource (i.e. STAMINA, MANA, VIM, ...)
typical tactical table entry: {STAMINA = 2}
want range: (-10, +10)
The want values for most standard resources are computed automatically and need no special treatment.
The default want computation evaluates the resource level vs. self.AI_RESOURCE_LEVEL_TRIGGER (default 0.90).  It assumes one turn of regeneration (if > 0) and is adjusted for global speed (faster increases want).
want vs % of self.AI_RESOURCE_LEVEL_TRIGGER (global_speed = 1): 0.1@100%, 2.0@35%, 5.4@10%, 9.9@0%
A TACTIC is automatically defined by ActorAI.AI_InitializeData() for all actor resources defined when tome.load.lua is run.
Resources can define a specialized want calculation in their definitions as resources_def.ai.tactical.want_level(self, aitarget).  (See the definitions for equilibrium, paradox, and psi for examples.)

--== FEEDBACK ==--
description: action replenishes the feedback pseudo-resource
want range: [0, +10)
The want computation is similar to standard resources, but is less aggressive and does not check self.AI_RESOURCE_LEVEL_TRIGGER.
want vs depleted(global_speed = 1): 0.00@0%, 0.03@20%, 0.40@50%, 2.00@76%, 4.79@90%, 9.92@100%

--== AMMO ==--
description: action replenishes ammunition
typical tactical table entry: {AMMO = 2}
want range: [0, +10)
The want computation requires SELF to have a quiver equipped, and depends on both how much ammo has been used and maximum ammo capacity.  Reloading is more aggressive with a smaller quiver.
want vs shots left (20 shot quiver): 0@20, 0.20@15, 1.11@10, 3.60@5, 10.0@0

--== MOVE ==--
description: an internally used tactic (not used in tactical tables) reflecting how much SELF needs to move from the current grid.  Used to avoid suffocating or damaging terrain.  May modify want.escape or want.closein.
want range: [0, +10)
The want computation is only performed if want.life or want.air > 0 while on damaging or suffocating terrain ( SELF:aiGridDamage)

--== ESCAPE ==--
description: action increases range to the target, prevents attacks (against SELF only), or avoids bad terrain
typical tactical table entry (T_PHASE_DOOR): {ESCAPE = 2}
want range: [-5, +10)
Base want.escape is want.life/2 - 1.  If the main target is closer that SELF.ai_tactic.safe_range (if defined), want.escape will be increased (~+2 @ 2/3 safe range).

--== CLOSEIN ==--
description: action decreases the range to AITARGET
typical tactical table entry (T_RUSH): {CLOSEIN = 3}
want range: [-10, +10)
The want computation is based on comparing the range to the target to the desired attack range for all of SELF's talents.  (It is slightly less than the median range.)  It is increased or decreased based on the difference, and want.escape is subtracted from it.

--== DEFEND ==--
description: action increase defenses/resistances or prevents damage or detrimental effects
typical tactical table entry (T_RESONANCE_FIELD): {DEFEND = 2}
want range: [0, +10)
Base want.defend is want.life/2, but always >= 0.1.  It is increased by up to +5 based on the number and rank of foes nearby.
want vs number of adjacent (rank 2) foes (at full health): 1.11@1, 1.82@2, 2.30@3, 2.67@4, 3.16@6, 3.48@8
	(+1.5 if adjacent to the (hostile) player only)

--== PROTECT ==--
description: action can help defend/assist SELF's summoner
typical tactical table entry (T_GOLEM_TAUNT): {PROTECT = 3}
want range: [0, +10)
The want computation is only performed if SELF.summoner is defined.  It is similar to want.life, but more sensitive to life loss and applies to SELF.summoner instead of SELF.
want vs. summoner life (ally_compassion = 5): 0.00@100%, 0.33@90%, 2.00@71%, 4.44@50%, 7.90@20%, 8.98@10%, 9.99@0%

--== SURROUNDED ==--
description: action is (offensively) useful when surrounded by foes
typical tactical table entry (T_GOLEM_REFLECTIVE_SKIN): {SURROUNDED = 3}
want range: [0, +10)
The want computation is based on the total relative strength of nearby foes vs allies.  It is similar to want.defend but is not affected by life levels.
want vs number of adjacent (rank 2) foes: 0.77@1, 1.43@2, 2.00@3, 2.50@4, 2.94@5, 3.33@6, 3.68@7, 4.00@8.
	(+1.11 if adjacent to the (hostile) player only)

--== DISABLE ==--
description: action hinders the target or reduces its damage, usually applying detrimental status effects to it
typical tactical table entry (T_STUN): {DISABLE = {stun = 2}}
want range: [0, +10)
The want computation assumes that the value of disabling abilities increase with the expected fight duration.  It estimates how long combat is likely to take (based on the apparent strength of AITARGET through a comparison of  life values, how long SELF has already been in combat, and how many attacks have been used during the current fight).  The WANT VALUE is also increased with want.life and want.cure (as a means to buy time).
At full health and with no detrimental effects, against an apparently "equal" foe, want.disable begins at ~2.7 early in combat, but settles to ~2.0 over time if half of the actions against the target have been attacks.

--== BUFF ==--
description: action improves the effectiveness of SELF's attacks, often applying beneficial status effects
typical tactical table entry: {BUFF = 2}
want range: [0.1, ~ want.attack*best attack tactic value/best buff tactic value)
The want computation is performed for each individual talent during the RAW TACTICAL SCORE calculation step after all other WANT VALUEs have been computed.  It uses similar assumptions to the want.disable calculation regarding expected fight duration (except for sustained talents), but adjusts for multiple hostile targets, range to target, and the number of currently available attacks that can reach the target.
The WANT VALUE scales with the best attack TACTIC WEIGHT so that the best buff TACTIC WEIGHT is close to (within the random range of) the best attack TACTIC WEIGHT.  This ensures that buffs always have a chance to be used before the attacks that they augment but are not used to the exclusion of those attacks. (See the actual code below for more details.)
In addition, the WANT VALUE is penalized while fleeing (want.escape > want.attack, typically), and decreases (possibly to negative values) as the range to the target exceeds the desired range for SELF's attacks.  This prevents the AI from wasting buff actions when it can't follow-up with attacks.
With a TACTIC WEIGHT of 2, the WANT VALUE approaches +2 against an "equal" opponent.

--== SPECIAL ==--
description: custom tactic
typical tactical table entry (T_SHOOT_DOWN): {SPECIAL = 10} or {SPECIAL = function(self, t, aitarget) ...}
want: always 1

The tactic value should account for a fixed WANT VALUE of 1, so a useful tactic should generally have a tactic value in the range of 4 (2x2) to 9 (3x3).  Unlike other TACTICs, the tactic value is not adjusted by reaction to the targets affected.

--==ADDITIONAL TACTICS ==--
Additional TACTICs can be defined for this AI.  ActorAI.AI_TACTICS must have a numerical benefit coefficient (usually -1 or +1, default -1) and ActorAI.AI_TACTICS_WANTS must include a function to compute the corresponding WANT VALUE.

For a new TACTIC called "my_tactic":

	ActorAI.AI_TACTICS.my_tactic = 1
	ActorAI.AI_TACTICS_WANTS.my_tactic = function(self, want, actions, avail) ... <return number> end

The function should return a WANT VALUE between -10 and +10, (<= 0 when "my_tactic" is not useful to SELF).  It is called immediately before the SELF.ai_tactic table coefficients are applied.  The function can also add additional actions to the actions table (using the format described in the ALGORITHM and IMPLEMENTATION section above) if needed.

Example -- adding a new tactic using the "ToME:load" hook:

	class:bindHook("ToME:load", function(self, data)
		print("Adding new tactic my_tactic at ToME:load hook")
		local ActorAI = require "mod.class.interface.ActorAI"
		ActorAI.AI_TACTICS.my_tactic = 1 -- define a benefit coefficient for the tactic
		ActorAI.AI_TACTICS_WANTS.my_tactic = function(self, want, actions, avail) -- want value computation
			print("### calculating want for my_tactic:")
			print("###want:") table.print(want, "\t_want_ ")
			print("###actions:") table.print(actions, "\t_actions_ ")
			print("###avail:") table.print(avail, "\t_avail_ ")
			return 2 -- want value (usually more complex than this)
		end
	end)

Note that want, actions, avail, and fight_data are stored in SELF.ai_state in the _want, _actions, _avail, and _fight_data fields respectively.
If may also be helpful to add more substitute damage types to ActorAI.aiSubstDamtypes.
The ActorAI.aiDHashProps and ActorAI.aiOHashProps tables should be updated for any actor attributes that affect how the new TACTIC's tactical tables are computed.
Last edited by Hachem_Muche on Mon Jul 17, 2017 12:19 am, edited 9 times in total.
Author of the Infinite 500 and PlenumTooltip addons, and the joys of Scaling in ToME.

Hachem_Muche
Uruivellas
Posts: 744
Joined: Thu Nov 18, 2010 6:42 pm

Re: Major AI Update

#3 Post by Hachem_Muche »

How to build Talent Tactical Tables:

(Note that code references refer to labels within the code in mod.class.interface.ActorAI.lua, see https://git.net-core.org/tome/t-engine4 ... quests/463)

Code: Select all

==== 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 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_state._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_procs{
		_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.
How to test tactical tables (in the Lua Console):
t = the talent definition
NPC = the npc (not player) using the talent
TARGET = the AI target for NPC
config.settings.log_detail_ai = 4 -- (or whatever level of detail you want output to the log file after each calculation)
config.settings.tactical_cache_test = true --(This ensures that the tactical table is fully recalculated each time.)

DO:
NPC.turn_procs = {} --(clears all temporary values, including the last calculated tactics and targets affected by the talent)
TACTICS = NPC:aiTalentTactics(t, TARGET) --(This assigns the TACTIC WEIGHTs to TACTICS.)

Assign a new tactical table to t if needed:
t.tactical = NPC.aiLowerTacticals(<new tactical table/function>)
REPEAT
It is normal to get "CACHE MISMATCH" errors between calls if you make changes to the tactical table.
Last edited by Hachem_Muche on Mon Jul 17, 2017 12:17 am, edited 5 times in total.
Author of the Infinite 500 and PlenumTooltip addons, and the joys of Scaling in ToME.

jenx
Sher'Tul Godslayer
Posts: 2263
Joined: Mon Feb 14, 2011 11:16 pm

Re: Major AI Update

#4 Post by jenx »

This is ABSOLUTELY fantastic !!!! You are to be congratulated on the production of a hugely important and significant upgrade. I can't wait to test this as an add-on.

We'll have to start calling you mini-DG if you keep up this good work ;-)


Also, I cannot but think that this will make the game tougher at all levels, but also make having minions/summons a lot more interesting as they will have the same new AI. I think this is great.

(btw, the uber-develoment would be to have an "intelligent" AI that changed weightings as it learned from successful attacks :-) )
MADNESS rocks

Post Reply