const.AIDecisionThreshold = 80 -- targets/locations up to this percent of max scored target/location can be selected const.AIPointBlankTargetMod = 50 -- targets in point-blank range get +50% score const.AIFallbackWeight_OpenDoor = 100 const.AIFallbackWeight_ClosedDoor = 40 const.AIFallbackWeight_Window = 70 const.AIAvoidFireWeigth = -200 const.AIAvoidGasWeigth = -200 const.AIAvoidBombardEdge = 100 -- % of score retained at the border of the zone const.AIAvoidBombardCenter = 30 -- % of score retained at the center of the zone const.AIFriendlyFire_MaxRange = 10 * const.SlabSizeX -- max range to ally for it to be considered in danger const.AIFriendlyFire_LOFWidth = 100*guic -- max distance from an ally to the line between position and target considered in danger const.AIFriendlyFire_LOFConeNear = 100*guic -- same as above for cone attacks (near side of the cone, positioned at attacker) const.AIFriendlyFire_LOFConeFar = 300*guic -- same as above for cone attacks (far side of the cone, positioned at AIFriendlyFire_MaxRange) const.AIFriendlyFire_ScoreMod = 50 -- % of damage score evaluation remanining when an ally is in danger const.AIShootAboveCTH = 0 local function CanReload(unit, weapon) if not IsKindOf(weapon, "Firearm") then return false end if (weapon.ammo and weapon.ammo.Amount or 0) >= weapon.MagazineSize then return false end if not unit:HasAP(CombatActions["Reload"]:GetAPCost(unit)) then return false end local ammo_type if (weapon.ammo and weapon.ammo.Amount or 0) > 0 then ammo_type = weapon.ammo.class end local ammo = unit:GetAvailableAmmos(weapon, ammo_type) if not ammo or not ammo[1] then return false end return true end function WaitIdle(unit) while IsValidTarget(unit) and not unit:IsIdleCommand() do WaitMsg("Idle", 200) end end local remove_action_cam_actions = { Move = true, MeleeAttack = true, ThrowGrenadeA = true, ThrowGrenadeB = true , ThrowGrenadeC = true, ThrowGrenadeD = true} function AIStartCombatAction(action_id, unit, ap, args, ...) if not ap then ap = CombatActions[action_id]:GetAPCost(unit, args, ...) end if not ap or ap < 0 or not unit:HasAP(ap, action_id) then return false end if ActionCameraPlaying then local waited if CurrentActionCamera.wait_signal then waited = true WaitMsg("ActionCameraWaitSignalEnd", 2000) end if remove_action_cam_actions[action_id] and g_Combat and g_Combat:IsVisibleByPoVTeam(unit) and not args.reposition then if not waited then Sleep(500) end RemoveActionCamera() end end if args and type(args) == "table" then if args.target then --HandleCameraTargetFixed(unit, args.target) ShowBadgeOfAttacker(unit, true) end if args.voiceResponse then PlayVoiceResponseGroup(unit, args.voiceResponse) elseif unit.ai_context and unit.ai_context.movement_action then local vr = unit.ai_context.movement_action:GetVoiceResponse() if vr then PlayVoiceResponseGroup(unit, vr) end end end local willBeTracked, visibleMovement if action_id == "Move" then willBeTracked, visibleMovement = AddToCameraTrackingBehavior(unit, args) args.willBeTracked = willBeTracked args.visibleMovement = visibleMovement end StartCombatAction(action_id, unit, ap, args, ...) return true end function AIPlayCombatAction(action_id, unit, ap, args) --[[if args and IsKindOf(args.target, "Unit") then printf("%s (%d): %s vs %s", _InternalTranslate(unit.Name or ""), unit.handle, action_id, _InternalTranslate(args.target.Name or "")) end--]] if not AIStartCombatAction(action_id, unit, ap, args) then return false end WaitCombatActionsPostAction(unit) ClearAITurnContours() StopCinematicCombatCamera() return true end function AIStartChangeStance(unit, stance, target_pos) if unit.stance == stance then return true end local angle if target_pos and target_pos:IsValid() then angle = CalcOrientation(unit, target_pos) end local args = { angle = angle } local result if stance == "Standing" then result = AIStartCombatAction("StanceStanding", unit, nil, args) elseif stance == "Crouch" then result = AIStartCombatAction("StanceCrouch", unit, nil, args) elseif stance == "Prone" then result = AIStartCombatAction("StanceProne", unit, nil, args) end return result or false end function AIPlayChangeStance(unit, stance, target_pos) if not AIStartChangeStance(unit, stance, target_pos) then return false end WaitCombatActionsPostAction(unit) return true end MapVar("g_AIDestIndoorsCache", {}) MapVar("g_AISignatureActionModifiers", {}) function AIUpdateContext(context, unit) unit = unit or context.unit context.unit_pos = GetPassSlab(unit) or context.unit_pos context.unit_stance_pos = GetPackedPosAndStance(unit) or context.unit_stance_pos context.unit_grid_voxel = point_pack(unit:GetGridCoords()) end function AIGetIntendedTarget(unit, context) context = context or unit.ai_context or empty_table local dest = context.ai_destination or GetPackedPosAndStance(unit) return (context.dest_target or empty_table)[dest] end function AILockTarget(unit, context) context = context or unit.ai_context local target = AIGetIntendedTarget(unit, context) if target then context.target_locked = target end end function AIGetAttackTargetingOptions(unit, context, target, action, targeting) local body_parts targeting = targeting or context.archetype.BaseAttackTargeting if IsKindOf(target, "Unit") and targeting then action = action or context.default_attack local args = { target = target, aim = 0 } local parts = target:GetBodyParts(context.weapon) local valid, fallback for _, part in ipairs(parts) do args.target_spot_group = part.id local results = action:GetActionResults(unit, args) body_parts = body_parts or {} results.chance_to_hit = results.chance_to_hit or 0 table.insert(body_parts, {id = part.id, chance = results.chance_to_hit}) if results.chance_to_hit > 0 then fallback = fallback or {id = part.id, chance = results.chance_to_hit} if targeting[part.id] then valid = true end end end if not valid then table.insert(body_parts, fallback) end end return body_parts end function AIPlayAttacks(unit, context, dbg_action, force_or_skip_action) -- filter enemies because they might have been killed by a teammate if g_AIExecutionController then g_AIExecutionController:Log("Unit %s (%d) start attack sequence", unit.unitdatadef_id, unit.handle) end local enemies = context.enemies for i = #enemies, 1, -1 do if not IsValidTarget(enemies[i]) then table.remove(enemies, i) end end local remaining_free_ap = unit.free_move_ap unit:RemoveStatusEffect("FreeMove") -- lose any remaining free movement points, we're going to use actions now AIUpdateContext(context, unit) if g_AIExecutionController then g_AIExecutionController:Log(" Num enemies: %d", #enemies) g_AIExecutionController:Log(" Action Points: %d", unit.ActionPoints) end local dest = not force_or_skip_action and context.ai_destination or GetPackedPosAndStance(unit) -- recalc target to make sure we're firing at a valid target, but prefer the already picked target if there's one --table.insert(g_AIDamageScoreLog, string.format("[%s] AIPlayAttacks (%s)", _InternalTranslate(unit.Name or ""), context.archetype.id)) context.dest_ap[dest] = context.dest_ap[dest] or unit.ActionPoints AIPrecalcDamageScore(context, {dest}, context.target_locked or (context.dest_target or empty_table)[dest]) -- archetype signature actions local signature_action if dbg_action then context.action_states = context.action_states or {} context.action_states[dbg_action] = {} dbg_action:PrecalcAction(context, context.action_states[dbg_action]) if dbg_action:IsAvailable(context, context.action_states[dbg_action]) then signature_action = dbg_action elseif force_or_skip_action then table.insert(failed_actions, dbg_action.BiasId or dbg_action.class) return end end if not context.reposition and not unit:HasStatusEffect("Numbness") then signature_action = signature_action or AIChooseSignatureAction(context) end local default_attack = context.default_attack local default_attack_vr = "AIAttack" if default_attack and default_attack.FiringModeMember and default_attack.FiringModeMember == "AttackShotgun" then default_attack_vr = "AIDoubleBarrel" end local voice_response = signature_action and (signature_action:GetVoiceResponse() or "") or default_attack_vr if voice_response == "" then voice_response = nil end if signature_action then if g_AIExecutionController then g_AIExecutionController:Log(" Signature Action: %s", signature_action:GetEditorView()) end signature_action:OnActivate(unit) --printf("[signature] %s (%d)", _InternalTranslate(unit.Name or ""), unit.handle) if voice_response then context.action_states[signature_action].args = context.action_states[signature_action].args or {} context.action_states[signature_action].args.voiceResponse = voice_response end local status = signature_action:Execute(context, context.action_states[signature_action]) context.ap_after_signature = unit.ActionPoints if status then -- support signature actions that want to restart or stop ai turn execution return status end AIReloadWeapons(unit) context.max_attacks = context.max_attacks - 1 else if g_AIExecutionController then g_AIExecutionController:Log(" No Signature Action chosen") end end local target = (context.dest_target or empty_table)[dest] if signature_action and (not IsValidTarget(target) or (IsKindOf(target, "Unit") and target:IsIncapacitated())) then --table.insert(g_AIDamageScoreLog, string.format("[%s] TargetChange (%s)", _InternalTranslate(unit.Name or ""), context.archetype.TargetChangePolicy)) if context.archetype.TargetChangePolicy == "restart" then return "restart" end context.dest_ap[dest] = unit.ActionPoints context.target_locked = nil AIPrecalcDamageScore(context, {dest}) target = context.dest_target[dest] end if IsValidTarget(target) then if g_AIExecutionController then g_AIExecutionController:Log(" Target: %s", IsKindOf(target, "Unit") and target.unitdatadef_id or target.class) end -- revert to basic attacks local attacks, aim = AICalcAttacksAndAim(context, unit.ActionPoints) if context.default_attack.id == "Bombard" and AICheckIndoors(dest) then attacks = 0 end local args = { target = target, voiceResponse = voice_response } if attacks > 1 then unit:SequentialActionsStart() end if g_AIExecutionController then g_AIExecutionController:Log(" Executing %d attacks...", attacks) end local body_parts = AIGetAttackTargetingOptions(unit, context, target) for i = 1, attacks do args.aim = aim[i] args.target_spot_group = nil if body_parts and #body_parts > 0 then local pick = table.weighted_rand(body_parts, "chance", InteractionRand(1000000, "Combat")) if pick then args.target_spot_group = pick.id end end Sleep(0) local result = AIPlayCombatAction(context.default_attack.id, unit, nil, args) context.max_attack = context.max_attacks - 1 if g_AIExecutionController then g_AIExecutionController:Log(" Attack %d result: %s", i, tostring(result)) end if IsSetpiecePlaying() then unit:SequentialActionsEnd() return end AIReloadWeapons(unit) if not result or i == attacks or not IsValidTarget(unit) or context.max_attacks <= 0 then break end while IsKindOf(target, "Unit") and target:IsGettingDowned() do WaitMsg("UnitDowned", 20) end if not IsValidTarget(target) or (IsKindOf(target, "Unit") and target:IsIncapacitated()) then --table.insert(g_AIDamageScoreLog, string.format("[%s] TargetChange (%s)", _InternalTranslate(unit.Name or ""), context.archetype.TargetChangePolicy)) if context.archetype.TargetChangePolicy == "restart" then unit:SequentialActionsEnd() return "restart" end -- look for another target context.dest_ap[dest] = unit.ActionPoints context.target_locked = nil AIPrecalcDamageScore(context, {dest}) target = context.dest_target[dest] if not IsValidTarget(target) then break end end Sleep(0) end unit:SequentialActionsEnd() elseif unit:HasStatusEffect("StationedMachineGun") and CombatActions.MGPack:GetUIState({unit}) == "enabled" then unit:SequentialActionsEnd() AIPlayCombatAction("MGPack", unit) return "restart" else if g_AIExecutionController then g_AIExecutionController:Log(" No target") end end unit:SequentialActionsEnd() while not unit:IsIdleCommand() do WaitMsg("Idle", 50) end if unit.ActionPoints + remaining_free_ap == context.start_ap and not unit:HasStatusEffect("ManningEmplacement") then -- no action was taken, use a fallback one -- if all fails, move toward optimal loc if context.closest_dest then unit:GainAP(remaining_free_ap) local dest = context.closest_dest local x, y, z, stance_idx = stance_pos_unpack(dest) local move_stance_idx = context.dest_combat_path[dest] local cpath = context.combat_paths[move_stance_idx] local pt = SnapToPassSlab(x, y, z) local path = pt and cpath and cpath:GetCombatPathFromPos(pt) if path then local goto_stance = StancesList[move_stance_idx] if goto_stance ~= unit.stance then AIPlayChangeStance(unit, goto_stance, point(point_unpack(path[2]))) end local goto_ap = unit.ActionPoints -- context.dest_ap[dest] --cpath.paths_ap[point_pack(x, y, z)] or 0 context.ai_destination = path[1] AIPlayCombatAction("Move", unit, goto_ap, { goto_pos = point(point_unpack(path[1])), fallbackMove = true, goto_stance = stance_idx }) end end if unit:GetDist(context.unit_pos) < const.SlabSizeX / 2 then local revert = true if context.archetype.FallbackAction == "overwatch" then -- try to place overwatch revert = not AIPlaceFallbackOverwatch(unit, context) end if revert then -- we're stuck somewhere and unable to move or act, revert back to being Unaware (only if no sight of any enemies) local sight = false for _, enemy in ipairs(context.enemies) do sight = sight or HasVisibilityTo(unit, enemy) end if not sight then table.insert(g_UnawareQueue, unit) end end end end end function AIPlaceFallbackOverwatch(unit, context) if not IsKindOf(context.weapon, "Firearm") then return false end if context.weapon.PreparedAttackType ~= "Overwatch" and context.weapon.PreparedAttackType ~= "Both" then return false end local target_pt local room = EnumVolumes(unit, "smallest") if room then -- indoors - overwatch against an open door/window or a closed one if none of them are opened local targets = {} room:ForEachSpawnedDoor(function(obj) local w = (obj.pass_through_state == "open" or obj.pass_through_state == "broken") and const.AIFallbackWeight_OpenDoor or const.AIFallbackWeight_ClosedDoor targets[#targets + 1] = { obj = obj, weight = w } end) room:ForEachSpawnedWindow(function(obj) targets[#targets + 1] = { obj = obj, weight = const.AIFallbackWeight_Window } end) if #targets > 0 then local target = table.weighted_rand(targets, "weight", InteractionRand(1000000, "AIDecision")) target_pt = target.obj:GetPos() end elseif context.unit.last_known_enemy_pos then target_pt = context.unit.last_known_enemy_pos else -- check for aware teammates that we can see local sp = GetPackedPosAndStance(unit) local targets = {} for _, ally in ipairs(context.allies) do if ally ~= context.unit and context.unit:GetDist(ally) < 12 * guim and stance_pos_visibility(sp, context.ally_pack_pos_stance[ally]) then -- try to find a point that we can (probably) see in front of our ally local v = Rotate(point(guim, 0, 0), ally:GetAngle()) for i = 6, 1, -1 do local tpt = SnapToPassSlab(ally:GetPos() + SetLen(v, i*guim)) if tpt then local x, y, z = tpt:xyz() local tsp = stance_pos_pack(x, y, z, StancesList.Standing) if stance_pos_visibility(sp, tsp) then targets[#targets + 1] = tpt break end end end end end if #targets == 0 then -- target in direction of alive enemy local revealed, all = {}, {} for _, enemy in ipairs(context.enemies) do if IsValidTarget(enemy) then all[#all + 1] = enemy if not enemy:HasStatusEffect("Hidden") then revealed[#revealed + 1] = enemy end end end local target_units = #revealed > 0 and revealed or all for _, enemy in ipairs(target_units) do targets[#targets + 1] = enemy:GetPos() + Rotate(point(InteractionRand(4*guim), 0, 0, InteractionRand(360*60))) end end if #targets > 0 then target_pt = table.interaction_rand(targets, "AIDecision") end end if target_pt then local args, has_ap = AIGetAttackArgs(context, CombatActions.Overwatch, nil, "None") if args and has_ap then args.target_pos = target_pt args.target = target_pt if AIPlayCombatAction("Overwatch", context.unit, nil, args) then PlayVoiceResponse(context.unit, "AIOverwatch") return true end end end return false end function AIExecuteUnitBehavior(unit, force_or_skip_action) if not g_Combat or not IsValid(unit) or unit:IsDead() then return end if unit.ai_context.behavior then local status = unit.ai_context.behavior:Play(unit) if g_AIExecutionController then g_AIExecutionController:Log(" Behavior %s for unit %s (%d) returned '%s'", unit.ai_context.behavior:GetEditorView(), unit.unitdatadef_id, unit.handle, tostring(status)) end if status then -- support behaviors that want to restart or stop the unit's ai return status end end -- recheck unit, they could be killed or despawned during Play if IsValid(unit) and not unit:IsDead() then -- use the rest of the ap (if any) in signature actions and basic attacks return AIPlayAttacks(unit, unit.ai_context, unit.ai_context.forced_signature_action, force_or_skip_action) or AITakeCover(unit) end end function AITakeCover(unit, context) local context = unit.ai_context if unit:HasPreparedAttack() or not context or ((context.ap_after_signature or 0) <= 0) then return end local cover_high, cover_low = GetCoverTypes(unit) if not cover_high and not cover_low then return end if unit.species == "Human" and unit.stance ~= "Prone" then local context = unit.ai_context local chance = context and context.behavior and context.behavior.TakeCoverChance or 0 if chance > 0 and (chance >= 100 or unit:Random(100) < chance) then local dest = GetPackedPosAndStance(unit) local enemy_visible = context.enemy_visible local enemy_pos = context.enemy_pack_pos_stance for _, enemy in ipairs(context.enemies) do if (enemy_visible[enemy] and GetCoverFrom(dest, enemy_pos[enemy]) or 0) > 0 then AIPlayCombatAction("TakeCover", unit, 0) return end end end end if cover_low then AIPlayCombatAction("StanceCrouch", unit, 0) end end function AIApplyActionModifiers(signature_action, unit) for _, mod in ipairs(signature_action.WeightModifications) do local id = mod.ActionId if id then local act_mods = g_AISignatureActionModifiers[id] or {} g_AISignatureActionModifiers[id] = act_mods local list if mod.ApplyTo == "Self" then list = act_mods[unit] or {} act_mods[unit] = list else list = act_mods[unit.team] or {} act_mods[unit.team] = {} end list[#list + 1] = { end_turn = g_Combat.current_turn + mod.Period, value = mod.Value } end end end function AIGetActionWeight(action, unit, action_state) local w = action.Weight local id = action.ActionId if id and id ~= "" then local mods = g_AISignatureActionModifiers[id] or empty_table if mods[unit] then w = w + mods[unit].total end if mods[unit.team] then w = w + mods[unit.team].total end end local score = action_state and action_state.score or 100 return MulDivRound(w, score, 100) end function AIGetSignatureActions(context, movement) local actions = {} -- if the behavior has any defined actions, pick from that list, otherwise revert to archetype's local actions_pool = context.behavior:GetSignatureActions(context) if not actions_pool or #actions_pool == 0 then actions_pool = context.archetype.SignatureActions end local unit = context.unit movement = movement or false for _, action in ipairs(actions_pool) do if (action.movement == movement) and action:MatchUnit(unit) then actions[#actions + 1] = action end end return actions end function AISelectAction(context, actions, base_weight, dbg_available_actions) local available = {} local weight = base_weight or 0 context.action_states = context.action_states or {} for _, action in ipairs(actions) do context.action_states[action] = {} local weight_mod, disable, priority = AIGetBias(action.BiasId, context.unit) disable = disable or context.disable_actions[action.BiasId or false] if not disable then action:PrecalcAction(context, context.action_states[action]) if action:IsAvailable(context, context.action_states[action]) then local action_weight = MulDivRound(action.Weight, weight_mod, 100) priority = priority or action.Priority if dbg_available_actions then table.insert(dbg_available_actions, { action = action, weight = action_weight, priority = priority }) end if priority then return action end available[#available + 1] = action available[available] = action_weight weight = weight + action_weight elseif dbg_available_actions then table.insert(dbg_available_actions, { action = action, weight = false }) end end end if weight > 0 then local roll = InteractionRand(weight, "AISignatureAction", context.unit) for _, action in ipairs(available) do local w = available[action] if roll <= weight then return action end roll = roll - weight end end return available[#available] end function AIChooseSignatureAction(context) local weight = context.archetype.BaseAttackWeight context.choose_actions = { { action = false, weight = weight, priority = false } }, AIUpdateBiases() local sig_actions = AIGetSignatureActions(context) return AISelectAction(context, sig_actions, weight, context.choose_actions) end function AIChooseMovementAction(context) local actions = AIGetSignatureActions(context, true) AIUpdateBiases() return AISelectAction(context, actions, context.archetype.BaseMovementWeight) end function AIFindDestinations(unit, context) local pos = GetPassSlab(unit) or unit:GetPos() local destinations, paths, dest_ap, dest_path, voxel_to_dest, closest_free_pos = AIBuildArchetypePaths(unit, pos, context) if not closest_free_pos then if unit.ActionPoints == 0 then assert(not "AI try to act with 0 action points!!!") else print("AI can't find unit free destination prints!!!") printf(" AP = %d", unit.ActionPoints) printf(" Command = %s", unit.command) printf(" Status effects: %s", table.concat(table.keys(unit.StatusEffects), ", ")) printf(" Pos: %s", tostring(unit:GetPos())) printf(" Pass slab pos: %s", tostring(GetPassSlab(unit) or "")) printf(" Target dummy pos %s", unit.target_dummy and tostring(unit.target_dummy:GetPos()) or "") local o = GetOccupiedBy(unit:GetPos(), unit) if o then printf("Other pos %s", tostring(o:GetPos())) printf("Other target dummy pos %s", o.target_dummy and tostring(o.target_dummy:GetPos()) or "") printf("Other efResting=%d", o:GetEnumFlags(const.efResting)) if o.reposition_dest then printf("Other reposition dest=%s", tostring(point(stance_pos_unpack(o.reposition_dest)))) end end assert(not "AI can't find unit free destination") end end local crouch_idx = StancesList.Crouch local important_dests = context.important_dests or {} context.important_dests = important_dests local change_stance_costs = {} for stance_idx in ipairs(StancesList) do change_stance_costs[stance_idx] = GetStanceToStanceAP(StancesList[stance_idx], "Crouch") end -- preprocess destinations to find those where we need to change stance at the dest to take cover local low = const.CoverLow --local high = const.CoverHigh for i, dest in ipairs(destinations) do local x, y, z, stance_idx = stance_pos_unpack(dest) if stance_idx ~= crouch_idx then local cost = change_stance_costs[stance_idx] local ap = dest_ap[dest] if cost and ap and ap >= cost then local up, right, down, left = GetCover(x, y, z) if up then local cover_low = up == low or right == low or down == low or left == low --local cover_high = up == high or right == high or down == high or left == high if cover_low then --and not cover_high then table.remove_value(important_dests, dest) local new_dest = stance_pos_pack(x, y, z, crouch_idx) destinations[i] = new_dest voxel_to_dest[point_pack(x, y, z)] = new_dest dest_ap[new_dest] = ap - cost dest_path[new_dest] = dest_path[dest] table.insert_unique(important_dests, new_dest) end end end end end context.destinations = destinations -- available destinations context.dest_ap = dest_ap -- dest -> available ap context.combat_paths = paths context.dest_combat_path = dest_path -- dest -> index in context.combat_paths (to reach this dest) context.voxel_to_dest = voxel_to_dest context.closest_free_pos = closest_free_pos context.all_destinations = AIEnumValidDests(context) end MapVar("g_BiasMarkers", false) function AICreateContext(unit, context) local gx, gy, gz = unit:GetGridCoords() local weapon = unit:GetActiveWeapons() local default_attack = unit:GetDefaultAttackAction(nil, "ungrouped", nil, "sync") local enemies = table.icopy(GetEnemies(unit)) for _, groupname in ipairs(unit.Groups) do local group_modifiers = gv_AITargetModifiers[groupname] for target_group, mod in pairs(group_modifiers) do for _, obj in ipairs(Groups[target_group]) do if IsKindOf(obj, "Unit") then table.insert_unique(enemies, obj) end end end end if not g_BiasMarkers then InitAIBiasMarkers() end -- fallback when our whole team doesn't have a visual on the enemy but we're still aware if #(enemies or empty_table) == 0 then enemies = table.ifilter(GetAllEnemyUnits(unit), function(idx, enemy) return not enemy:HasStatusEffect("Hidden") end) end -- special-case when having ManningEmplacement status - filter out non targetable enemies if unit:HasStatusEffect("ManningEmplacement") then enemies = table.ifilter(enemies, function(idx, enemy) return enemy:IsThreatened({unit}) end) end table.sortby_field(enemies, "handle") local pos = GetPassSlab(unit) if not pos then -- can happen if the unit is on impassable for some reason --assert(false, "GetPassSlab failed for unit " .. unit.session_id) local x, y, z = unit:GetPosXYZ() local gx, gy, gz = WorldToVoxel(x, y, z) if not z then gz = nil end pos = point(VoxelToWorld(gx, gy, (gz))) end local wx, wy, wz = pos:xyz() context = context or {} context.unit = unit context.unit_pos = pos context.start_ap = unit.ActionPoints context.archetype = unit:GetArchetype() context.unit_grid_voxel = point_pack(gx, gy, gz) context.unit_world_voxel = point_pack(pos) context.unit_stance_pos = stance_pos_pack(wx, wy, wz, StancesList[unit.stance]) context.max_attacks = unit.MaxAttacks context.dest_target = {} -- dest -> picked target (if any) context.dest_target_score = {} -- dest -> estimated damage context.weapon = weapon context.default_attack = default_attack context.default_attack_cost = default_attack:GetAPCost(unit) context.EffectiveRange = IsKindOf(weapon, "Firearm") and weapon.WeaponRange / 2 or 1 context.ExtremeRange = IsKindOf(weapon, "Firearm") and weapon.WeaponRange or 1 context.enemies = enemies context.enemy_visible = {} -- [enemy] -> true/false context.enemy_visible_by_team = {} -- [enemy] -> true/false context.enemy_pos = {} context.enemy_grid_voxel = {} context.enemy_pack_pos_stance = {} context.enemy_dir = {} context.stance_pos_to_vis_enemies = {} context.allies = unit.team.units context.ally_grid_voxel = {} context.ally_pack_pos_stance = {} context.ally_pos = {} context.voxel_heal_target = {} context.voxel_heal_score = {} context.forced_signature_action = false context.apply_bias = true context.disable_actions = {} -- support for custom filtering for signature action selection by BiasId NetUpdateHash("AICreateContext", unit, pos, unit.stance, context.start_ap, context.archetype.id, context.max_attacks, weapon and weapon.class, weapon and weapon.id, default_attack.id) if unit:HasStatusEffect("Stimmed") then context.max_attacks = context.max_attacks + 1 end for _, action in ipairs(context.archetype.SignatureActions) do context.can_heal = context.can_heal or IsKindOf(action, "AIActionBandage") end if not context.can_heal then for _, behavior in ipairs(context.archetype.Behaviors) do for _, action in ipairs(behavior.SignatureActions) do context.can_heal = context.can_heal or IsKindOf(action, "AIActionBandage") end end end for i, enemy in ipairs(enemies) do local x, y, z = enemy:GetGridCoords() context.enemy_grid_voxel[enemy] = point_pack(x, y, z) context.enemy_pack_pos_stance[enemy] = GetPackedPosAndStance(enemy) local enemy_pos = GetPassSlab(enemy) or SnapToVoxel(enemy:GetPos()) context.enemy_pos[enemy] = enemy_pos if not pos:Equal2D(enemy_pos) then local dir = enemy_pos - pos dir = dir:SetInvalidZ() context.enemy_dir[enemy] = SetLen(dir, guim) else context.enemy_dir[enemy] = point(0, 0, guim) end context.enemy_visible[enemy] = HasVisibilityTo(unit, enemy) context.enemy_visible_by_team[enemy] = HasVisibilityTo(unit.team, enemy) end if context.behavior then context.behavior:EnumDestinations(unit, context) else AIFindDestinations(unit, context) end AIUpdateDestLosCache(unit, context) for i, ally in ipairs(context.allies) do local x, y, z = ally:GetGridCoords() context.ally_grid_voxel[ally] = point_pack(x, y, z) context.ally_pack_pos_stance[ally] = GetPackedPosAndStance(ally) context.ally_pos[ally] = ally:GetPos() end unit.ai_context = context return context end MapVar("g_AIDestEnemyLOSCache", {}) function dbgShowAIDestCache() DbgClearVectors() DbgClearTexts() for dest, los in pairs(g_AIDestEnemyLOSCache) do local x, y, z, stance_idx = stance_pos_unpack(dest) z = z or terrain.GetHeight(x, y) DbgAddVector(point(x, y, z), point(0, 0, guim), los and const.clrGreen or const.clrRed) DbgAddText(StancesList[stance_idx], point(x, y, z), const.clrWhite) end end function AIUpdateDestLosCache(unit, context) assert(CurrentThread()) -- the function will sleep internally due to the amount of calculations performed --local tStart = GetPreciseTicks() --ic("AIUpdateDestLosCache start", #units) local sight = unit:GetSightRadius() local all_destinations = context.all_destinations local enemies = context.enemies if #enemies == 0 then return end NetUpdateHash("AIUpdateDestLosCache_Start", GameTime(), sight, #all_destinations, hashParamTable(all_destinations), #enemies, hashParamTable(context.enemy_pack_pos_stance)) local dests local los_cache = g_AIDestEnemyLOSCache for _, dest in ipairs(all_destinations) do if los_cache[dest] == nil then if not dests then dests = {} end dests[#dests + 1] = dest los_cache[dest] = false end end if dests then local max_los_checks = 100 local targets = {} local srcs = {} local enemies_count = #enemies local next_dest_idx = 1 local start_dest_idx = 1 local cur_enemy = 1 while true do local ppos = context.enemy_pack_pos_stance[enemies[cur_enemy]] local count = #targets local last_dest_idx = Min(#dests, next_dest_idx + max_los_checks - count - 1) for i = next_dest_idx, last_dest_idx do count = count + 1 targets[count] = ppos srcs[count] = dests[i] end next_dest_idx = last_dest_idx + 1 if next_dest_idx > #dests then next_dest_idx = 1 cur_enemy = cur_enemy + 1 end if count >= max_los_checks or cur_enemy > enemies_count then local los_any, los_data = CheckLOS(targets, srcs, sight) if los_any then local visible_dests = 0 for i, value in ipairs(los_data) do if value then local dest = srcs[i] if not los_cache[dest] then los_cache[dest] = true visible_dests = visible_dests + 1 end end end if visible_dests >= #dests then break end if cur_enemy < enemies_count or cur_enemy == enemies_count and next_dest_idx == 1 then -- There will be more LOS checks. Remove visible destinations from dests list to not cast more lines from there if #targets >= #dests then for i = #dests, 1, -1 do if los_cache[dests[i]] then table.remove(dests, i) if i < next_dest_idx then next_dest_idx = next_dest_idx - 1 end end end elseif start_dest_idx <= last_dest_idx then for i = last_dest_idx, start_dest_idx, -1 do if los_cache[dests[i]] then table.remove(dests, i) if i < next_dest_idx then next_dest_idx = next_dest_idx - 1 end end end else for i = #dests, start_dest_idx, -1 do if los_cache[dests[i]] then table.remove(dests, i) if i < next_dest_idx then next_dest_idx = next_dest_idx - 1 end end end for i = last_dest_idx, 1, -1 do if los_cache[dests[i]] then table.remove(dests, i) if i < next_dest_idx then next_dest_idx = next_dest_idx - 1 end end end end if #dests == 0 then assert(#dests > 0) break end end end if cur_enemy > enemies_count then break end start_dest_idx = next_dest_idx table.iclear(targets) table.iclear(srcs) if GetInGameInterfaceMode() ~= "IModeAIDebug" then Sleep(10) --yield end end end end NetUpdateHash("AIUpdateDestLosCache_End", GameTime()) --printf("AIUpdateDestLosCache: %d ms for %s", GetPreciseTicks() - tStart, unit.unitdatadef_id) end function AIHasLOSToEnemyFromDest(dest) return not not g_AIDestEnemyLOSCache[dest] end function AICalcAttacksAndAim(context, ap) local aim_cost = const.Scale.AP if GameState.RainHeavy then aim_cost = MulDivRound(aim_cost, 100 + const.EnvEffects.RainAimingMultiplier, 100) end local cost = context.default_attack_cost local num_attacks = Min(ap / cost, context.max_attacks) if context.force_max_aim then num_attacks = Min(ap / (cost + aim_cost * context.weapon.MaxAimActions), context.max_attacks) end local remaining = ap - num_attacks * cost local aims = {} local attack_idx = 1 while remaining > aim_cost do local aim = (aims[attack_idx] or 0) + 1 if aim > context.weapon.MaxAimActions then break end aims[attack_idx] = aim attack_idx = attack_idx + 1 if attack_idx > num_attacks then attack_idx = 1 end remaining = remaining - aim_cost end return num_attacks, aims end function AIBuildArchetypePaths(unit, pos, context) local stationary = context.stationary local paths = {} local destinations, dest_path, dest_ap, voxel_to_dest = {}, {}, {}, {} if stationary or CombatActions.Move:GetUIState{unit} ~= "enabled" then local dest = GetPackedPosAndStance(unit) local x, y, z = stance_pos_unpack(dest) local voxel = point_pack(x, y, z) destinations[1] = dest dest_ap[dest] = unit.ActionPoints voxel_to_dest[voxel] = dest return destinations, paths, dest_ap, dest_path, voxel_to_dest, voxel end local archetype = unit:GetArchetype() local goto_stance = archetype.MoveStance local pref_stance = archetype.PrefStance local move_stance_idx = StancesList[goto_stance] or 0 local pref_stance_idx = StancesList[pref_stance] or 0 local ps_ap = (unit.species == "Human") and (unit.ActionPoints - GetStanceToStanceAP(unit.stance, pref_stance)) or unit.ActionPoints local ms_ap = (unit.species == "Human") and (unit.ActionPoints - GetStanceToStanceAP(unit.stance, goto_stance)) or unit.ActionPoints local move_path = CombatPath:new() move_path:RebuildPaths(unit, ms_ap, pos, goto_stance) local dest_voxels = table.keys(move_path.destinations, true) local pref_path if goto_stance == pref_stance then pref_path = move_path else local visited = move_path.destinations pref_path = CombatPath:new() pref_path:RebuildPaths(unit, ps_ap, pos, pref_stance) for voxel in sorted_pairs(pref_path.destinations) do if not visited[voxel] then dest_voxels[#dest_voxels+1] = voxel end end end local important_dests = context.important_dests or {} local min_melee_dist = 2 * const.SlabSizeX local move_paths_ap = move_path.paths_ap local pref_paths_ap = pref_path.paths_ap for _, voxel in ipairs(dest_voxels) do local x, y, z = point_unpack(voxel) local move_ap = move_paths_ap[voxel] local pref_ap = pref_paths_ap[voxel] local mn_ap = move_ap and (ms_ap - move_ap) or -1 local pn_ap = pref_ap and (ps_ap - pref_ap) or -1 local dest if pn_ap > mn_ap then assert(pref_ap) dest = stance_pos_pack(x, y, z, pref_stance_idx) destinations[#destinations+1] = dest dest_path[dest] = pref_stance_idx dest_ap[dest] = pn_ap elseif move_ap then dest = stance_pos_pack(x, y, z, move_stance_idx) destinations[#destinations+1] = dest dest_path[dest] = move_stance_idx dest_ap[dest] = mn_ap else dest = stance_pos_pack(x, y, z, StancesList[unit.stance]) assert(dest == context.unit_stance_pos) destinations[#destinations+1] = dest dest_path[dest] = move_stance_idx dest_ap[dest] = unit.ActionPoints end voxel_to_dest[voxel] = dest if not table.find(important_dests, dest) then if context.EffectiveRange <= 1 then -- make sure all potential melee positions are included in the end and not cut off by CollapsePoints for enemy, enemy_ppos in pairs(context.enemy_pack_pos_stance) do if stance_pos_dist(enemy_ppos, dest) < min_melee_dist then table.insert_unique(important_dests, dest) break end end end -- also do the same for allies, since we might wanna heal them if context.can_heal then for _, ally in ipairs(context.allies) do local ppos = GetPackedPosAndStance(ally) if stance_pos_dist(ppos, dest) < min_melee_dist then table.insert_unique(important_dests, dest) break end end end end end destinations = CollapsePoints(destinations, 1) context.important_dests = important_dests for _, dest in ipairs(important_dests) do if dest_ap[dest] and CanOccupy(unit, stance_pos_unpack(dest)) then table.insert_unique(destinations, dest) end end -- filter out destinations someone already called dibs for for _, u in ipairs(context.allies) do if u ~= unit and u.ai_context then local idx = table.find(destinations, u.ai_context.ai_destination) if idx then destinations[idx] = destinations[#destinations] destinations[#destinations] = nil end end end paths[goto_stance] = move_path paths[move_stance_idx] = move_path paths[pref_stance] = pref_path paths[pref_stance_idx] = pref_path return destinations, paths, dest_ap, dest_path, voxel_to_dest, move_path.closest_free_pos end function AIScoreDest(context, policies, dest, grid_voxel, base_score, visual_voxels, score_details) local score = 0 local x, y, z, stance_idx = stance_pos_unpack(dest) if not grid_voxel then local vx, vy, vz = WorldToVoxel(x, y, z) grid_voxel = point_pack(vx, vy, vz) end local voxels, head = context.unit:GetVisualVoxels(point_pack(x, y, z), StancesList[stance_idx], visual_voxels) if AreVoxelsInFireRange(voxels) then score = const.AIAvoidFireWeigth if score_details then score_details[#score_details + 1] = "ADJACENT FIRE" score_details[#score_details + 1] = const.AIAvoidFireWeigth end elseif g_SmokeObjs[head] then score = const.AIAvoidFireWeigth if score_details then score_details[#score_details + 1] = "GASSED AREA" score_details[#score_details + 1] = const.AIAvoidGasWeigth end end for _, policy in ipairs(policies) do local peval = policy:EvalDest(context, dest, grid_voxel) local pscore = MulDivRound(peval or 0, policy.Weight, 100) local failed = policy.Required and pscore == 0 score = score + pscore if score_details then score_details[#score_details + 1] = (failed and "[FAILED] " or "") .. policy:GetEditorView() score_details[#score_details + 1] = pscore end if failed then return 0 end end score = (base_score or 0) + score -- bombard zone modifier for _, zone in ipairs(g_Bombard) do local dist = zone:GetDist(x, y, z) local radius = zone.radius * const.SlabSizeX if dist <= radius then local mod = MulDivRound(dist, const.AIAvoidBombardEdge, radius) + MulDivRound(radius - dist, const.AIAvoidBombardCenter, radius) local loss = MulDivRound(score, 100 - mod, 100) if score_details and loss > 0 then score_details[#score_details + 1] = "BOMBARD ZONE" score_details[#score_details + 1] = -loss end score = Max(0, score - loss) end end -- apply modifiers from bias markers at the end if context.apply_bias then local unit = context.unit for _, marker in ipairs(g_BiasMarkers) do local bias = marker:GetAIBias(unit, dest) if bias ~= 100 then score = MulDivRound(score, bias, 100) if score_details then score_details[#score_details + 1] = string.format("Bias Marker %s (%%): ", marker.ID) score_details[#score_details + 1] = bias end end end end return score end MapSlabsBBox_MaxZ = 100000 function AIEnumValidDests(context) local unit = context.unit local r = context.archetype.OptLocSearchRadius * const.SlabSizeX local ux, uy, uz = point_unpack(context.unit_grid_voxel) local px, py, pz = VoxelToWorld(ux, uy, uz) local bbox = box(px - r, py - r, 0, px + r + 1, py + r + 1, MapSlabsBBox_MaxZ) local dests, dest_added = {}, {} local function push_dest(x, y, z, context, dests, dest_added, ux, uy, uz) local gx, gy, gz = WorldToVoxel(x, y, z) if not IsCloser(gx, gy, gz, ux, uy, uz, context.archetype.OptLocSearchRadius) then return end if not CanOccupy(unit, x, y, z) then return end local world_voxel = point_pack(x, y, z) local dest = context.voxel_to_dest[world_voxel] if not dest then dest = stance_pos_pack(x, y, z, StancesList[context.archetype.PrefStance]) end if not dest_added[dest] then dests[#dests + 1] = dest dest_added[dest] = true end end ForEachPassSlab(bbox, push_dest, context, dests, dest_added, ux, uy, uz) -- add current pos if not dest_added[context.unit_stance_pos] then local x, y, z = stance_pos_unpack(context.unit_stance_pos) if CanOccupy(unit, x, y, z) then dests[#dests + 1] = context.unit_stance_pos dest_added[context.unit_stance_pos] = true end end -- add from context.destinations for _, dest in ipairs(context.destinations) do if not dest_added[dest] then dests[#dests + 1] = dest end end dests = CollapsePoints(dests, 1) for _, dest in ipairs(context.important_dests) do table.insert_unique(dests, dest) end return dests end function AIFindOptimalLocation(context, dest_score_details) if context.best_dest then -- optimal location doesn't change across behaviors, no need to recalc it return context.best_dest end local unit = context.unit context.best_dests = {} local r = context.archetype.OptLocSearchRadius * const.SlabSizeX local ux, uy, uz = point_unpack(context.unit_grid_voxel) local px, py, pz = VoxelToWorld(ux, uy, uz) local bbox = box(px - r, py - r, 0, px + r + 1, py + r + 1, MapSlabsBBox_MaxZ) context.best_score = 0 local unit_voxels = {} local dest_scores = {} local policies = table.ifilter(context.archetype.OptLocPolicies, function(idx, policy) return policy:MatchUnit(unit) end) for _, dest in ipairs(context.all_destinations) do local x, y, z = stance_pos_unpack(dest) local gx, gy, gz = WorldToVoxel(x, y, z) local world_voxel = point_pack(x, y, z) local grid_voxel = point_pack(gx, gy, gz) --eval_voxel(x, y, z, context, ux, uy, uz) if not context.voxel_to_dest[world_voxel] then context.voxel_to_dest[world_voxel] = dest end local scores if dest_score_details then scores = {} dest_score_details[dest] = scores end table.iclear(unit_voxels) local score = AIScoreDest(context, policies, dest, grid_voxel, 0, unit_voxels, scores) if score > 0 then context.best_score = Max(context.best_score, score) local threshold = MulDivRound(context.best_score, const.AIDecisionThreshold, 100) if score >= threshold then dest_scores[dest] = score context.best_dests[#context.best_dests + 1] = dest for i = #context.best_dests, 1, -1 do local dest = context.best_dests[i] if dest_scores[dest] < threshold then table.remove(context.best_dests, i) end end end end if scores then scores.final_score = score end end -- check if a best dest candidate is on our starting voxel, default to it for _, dest in ipairs(context.best_dests) do if stance_pos_dist(context.unit_stance_pos, dest) == 0 then context.best_dest = dest end end if not context.best_dest and #(context.best_dests or empty_table) > 0 then if #(context.best_dests or empty_table) > 15 then context.collapsed = CollapsePoints(context.best_dests, 1) else context.collapsed = context.best_dests end local pf_dests = {} for i, dest in ipairs(context.collapsed) do local x, y, z = stance_pos_unpack(dest) pf_dests[i] = point(x, y, z) end context.best_dest_path = pf.GetPosPath(unit, pf_dests) if #(context.best_dest_path or empty_table) > 0 then local voxel = point_pack(SnapToPassSlabXYZ(context.best_dest_path[1])) local dest = context.voxel_to_dest[voxel] if not dest then -- try non-snapped voxel = point_pack(context.best_dest_path[1]) dest = context.voxel_to_dest[voxel] end --assert(dest and (not dest_score_details or dest_score_details[dest])) context.best_dest = dest end end context.dest_scores = dest_scores context.best_dest = context.best_dest or context.voxel_to_dest[context.unit_world_voxel] or context.unit_stance_pos if context.dest_combat_path[context.best_dest] then table.insert_unique(context.important_dests, context.best_dest) table.insert_unique(context.destinations, context.best_dest) end return context.best_dest end function AICalcPathDistances(context) local unit = context.unit local path_voxels, voxel_dist, total_dist if context.best_dest_path then path_voxels, voxel_dist, total_dist = CalcPathVoxels(context.best_dest_path) end context.path_voxels = path_voxels context.path_to_target = table.copy(path_voxels or empty_table) context.voxel_dist = voxel_dist context.total_dist = total_dist -- calc distance to optimal location from each dest if path_voxels and voxel_dist then AICalcDistancesFromReachableLocations(context) -- will add path nodes to path_voxels and voxel_dist else -- no path to target, use default distances on all reachable voxels context.dest_dist = {} end end function AIGetWeaponCheckRange(unit, weapon, action) if IsKindOf(weapon, "MeleeWeapon") then local tiles = unit.body_type == "Large animal" and 2 or 1 local range = (2 * tiles + 1) * const.SlabSizeX / 2 return range, true elseif IsKindOf(weapon, "Firearm") then local max_range = weapon.WeaponRange * const.SlabSizeX if action.AimType ~= "cone" then max_range = 15 * max_range / 10 end return max_range end end --MapVar("g_AIDamageScoreLog", {}) function AIAllyInDanger(allies, ally_pos, pos, target, dist_near, dist_far) local target_pos = target:GetPos() local v = target:GetPos() - pos local d = const.AIFriendlyFire_MaxRange for _, ally in ipairs(allies) do if ally:GetDist2D(pos) <= const.AIFriendlyFire_MaxRange then local ally_pos = ally_pos and ally_pos[ally] or ally:GetPos() local dist, x, y, z = DistSegmentToPt2D(pos, target_pos, ally_pos) local nearest = point(x, y, z) local d1 = pos:Dist2D(nearest) local dist_threshold = MulDivRound(dist_near, Clamp(0, d, d - d1), d) + MulDivRound(dist_far, Clamp(0, d, d1), d) if dist < dist_threshold then local v1 = nearest - pos if Dot2D(v, v1) > 0 then return true end end end end end function AIPrecalcDamageScore(context, destinations, preferred_target, debug_data) local unit = context.unit local weapon = context.weapon local action = CombatActions[context.override_attack_id or false] or context.default_attack local archetype = context.archetype local behavior = context.behavior if not weapon or context.reposition or unit:HasStatusEffect("Burning") then return end if not destinations and context.damage_score_precalced then return end local action_targets = action:GetTargets({unit}) local targets = table.ifilter(action_targets, function(idx, target) return unit:IsOnEnemySide(target) end) if #targets == 0 then return end context.damage_score_precalced = true local target_score_mod = {} local tsr = archetype.TargetScoreRandomization for i, target in ipairs(targets) do target_score_mod[i] = 100 + ((tsr > 0) and unit:RandRange(-tsr, tsr) or 0) end context.target_score_mod = target_score_mod local base_mod = unit[weapon.base_skill] local cost_ap = context.override_attack_cost or context.default_attack_cost local max_check_range, is_melee = AIGetWeaponCheckRange(unit, weapon, action) local is_heavy = IsKindOf(weapon, "HeavyWeapon") local hit_modifiers = Presets["ChanceToHitModifier"]["Default"] -- stance mod local modCrouchBonus = 0 local modProneBonus = 0 --if IsKindOf(weapon, "Firearm") then --modCrouchBonus = hit_modifiers.AttackerStance:ResolveValue("CrouchBonus") --modProneBonus = hit_modifiers.AttackerStance:ResolveValue("ProneBonus") local value = GetComponentEffectValue(weapon, "AccuracyBonusProne", "bonus_cth") if value then modProneBonus = modProneBonus + value end --end -- ground difference mod local MinGroundDifference = hit_modifiers.GroundDifference:ResolveValue("RangeThreshold") * const.SlabSizeZ / 100 local modHighGround = hit_modifiers.GroundDifference:ResolveValue("HighGround") local modLowGround = hit_modifiers.GroundDifference:ResolveValue("LowGround") -- cover local modCover = hit_modifiers.RangeAttackTargetStanceCover:ResolveValue("Cover") local modSameTarget = hit_modifiers.SameTarget:ResolveValue("Bonus") local target_policies = archetype.TargetingPolicies if behavior and #(behavior.TargetingPolicies or empty_table) > 0 then target_policies = behavior.TargetingPolicies end local dest_target = context.dest_target local dest_target_score = context.dest_target_score local dest_ap = context.dest_ap local aim_mod = Presets.ChanceToHitModifier.Default.Aim local dest_cth = {} context.dest_cth = dest_cth local lof_params local attacker_pos = unit:GetPos() -- script-driven modifiers (based on groups) local target_modifiers for _, groupname in ipairs(unit.Groups) do local group_modifiers = gv_AITargetModifiers[groupname] for target_group, mod in pairs(group_modifiers) do target_modifiers = target_modifiers or {} target_modifiers[target_group] = (target_modifiers[target_group] or 0) + mod for _, obj in ipairs(Groups[target_group]) do if IsKindOf(obj, "Unit") and not table.find(targets, obj) then table.insert(targets, obj) -- make sure the target is considired regardless if it's an enemy or not table.insert(target_score_mod, 100 + ((tsr > 0) and unit:RandRange(-tsr, tsr) or 0)) end end end end if unit:HasStatusEffect("StationedMachineGun") or unit:HasStatusEffect("ManningEmplacement") then local ow_units = {unit} targets = table.ifilter(targets, function(idx, target) return target:IsThreatened(ow_units, "overwatch") end) end if not IsValidTarget(preferred_target) or (IsKindOf(preferred_target, "Unit") and preferred_target:IsIncapacitated() or not table.find(targets, preferred_target)) then preferred_target = nil end if weapon and not is_melee then lof_params = { obj = unit, action_id = action.id, weapon = weapon, step_pos = false, stance = false, range = max_check_range, prediction = true, output_collisions = true, } if not destinations or #destinations > 1 then lof_params.target_spot_group = "Torso" end end --[[ local logdata = {} if destinations then table.insert(g_AIDamageScoreLog, logdata) end logdata.preferred_target = preferred_target and (IsKindOf(preferred_target, "Unit") and _InternalTranslate(preferred_target.Name or "") or preferred_target.class) or tostring(preferred_target)--]] destinations = destinations or context.destinations NetUpdateHash("AIPrecalcDamageScore", unit, hashParamTable(destinations), hashParamTable(targets), preferred_target) for j, upos in ipairs(destinations) do local ux, uy, uz, ustance_idx = stance_pos_unpack(upos) local ustance = StancesList[ustance_idx] uz = uz or terrain.GetHeight(ux, uy) local ap = dest_ap[upos] or 0 local best_target, best_cth local best_score = 0 local potential_targets, target_score, target_cth = {}, {}, {} if weapon and ap >= cost_ap then local pos_mod = base_mod pos_mod = pos_mod + (ustance_idx == 2 and modCrouchBonus or ustance_idx == 3 and modProneBonus or 0) local targets_attack_data if not is_melee then attacker_pos = point(ux, uy, uz) lof_params.step_pos = point_pack(ux, uy, uz) lof_params.stance = ustance targets_attack_data = GetLoFData(unit, targets, lof_params) end for k, target in ipairs(targets) do local tpos = GetPackedPosAndStance(target) local dist = stance_pos_dist(upos, tpos) if dist <= (max_check_range or dist) and (is_melee or targets_attack_data[k] and not targets_attack_data[k].stuck) then local tx, ty, tz, tstance_idx = stance_pos_unpack(tpos) tz = tz or terrain.GetHeight(tx, ty) local hit_mod = pos_mod if not is_heavy then hit_mod = hit_mod + (uz > tz + MinGroundDifference and modHighGround or uz < tz - MinGroundDifference and modLowGround or 0) hit_mod = hit_mod + (unit:GetLastAttack() == target and modSameTarget or 0) end local target_cover = GetCoverFrom(tpos, upos) if target_cover == const.CoverLow or target_cover == const.CoverHigh then hit_mod = hit_mod + modCover end local penalty = is_heavy and 0 or (100 - weapon:GetAccuracy(dist)) local mod = hit_mod - penalty --dist_penalty -- environmental modifiers when applicable local apply, value, target_spot_group, action, weapon1, weapon2, lof, aim, opportunity_attack apply, value = hit_modifiers.Darkness:CalcValue(unit, target, target_spot_group, action, weapon1, weapon2, lof, aim, opportunity_attack, attacker_pos) if apply then mod = mod + value end if not is_heavy and unit:IsPointBlankRange(target) then mod = MulDivRound(mod, 100 + const.AIPointBlankTargetMod, 100) end mod = Max(0, mod) if mod > const.AIShootAboveCTH then -- calc base score based on cth/attacks/aiming local base_mod = mod local attacks, aims = AICalcAttacksAndAim(context, ap) mod = 0 for i = 1, attacks do local use, bonus if (aims[i] or 0) > 0 then use, bonus = aim_mod:CalcValue(unit, nil, nil, nil, nil, nil, nil, aims[i]) end mod = mod + base_mod + (use and bonus or 0) end -- modify score by archetype-specific weight and (optional) targeting policies mod = MulDivRound(mod, archetype.TargetBaseScore, 100) for _, policy in ipairs(target_policies) do local peval = policy:EvalTarget(unit, target) mod = mod + MulDivRound(peval or 0, policy.Weight, 100) end if IsKindOf(target, "Unit") and (target:IsDowned() or target:IsGettingDowned()) then mod = MulDivRound(mod, 5, 100) end local attack_data = targets_attack_data and targets_attack_data[k] local ally_in_danger = attack_data and (attack_data.best_ally_hits_count or 0) > 0 if action and action.AimType == "cone" then ally_in_danger = ally_in_danger or AIAllyInDanger(context.allies, context.ally_pos, attacker_pos, target, const.AIFriendlyFire_LOFConeNear, const.AIFriendlyFire_LOFConeFar) else ally_in_danger = ally_in_danger or AIAllyInDanger(context.allies, context.ally_pos, attacker_pos, target, const.AIFriendlyFire_LOFWidth, const.AIFriendlyFire_LOFWidth) end if ally_in_danger then mod = MulDivRound(mod, const.AIFriendlyFire_ScoreMod, 100) end mod = MulDivRound(mod, target_score_mod[k], 100) -- apply group-based modifiers if target_modifiers and IsKindOf(target, "Unit") then local group_mod = 0 for _, groupname in ipairs(target.Groups) do group_mod = group_mod + (target_modifiers[groupname] or 0) end if group_mod > 0 then mod = MulDivRound(mod, group_mod, 100) end end --[[table.insert(logdata, { name = IsKindOf(target, "Unit") and _InternalTranslate(target.Name or "") or target.class, score = mod })--]] if mod > 0 and target == preferred_target then best_target = target best_score = mod best_cth = base_mod potential_targets = {} break end best_score = Max(best_score, mod) target_cth[target] = base_mod target_score[target] = mod local threshold = MulDivRound(best_score or 0, const.AIDecisionThreshold, 100) if mod >= threshold then potential_targets[#potential_targets + 1] = target for i = #potential_targets, 1, -1 do local target = potential_targets[i] local score = target_score[target] if score < threshold then table.remove(potential_targets, i) end end --best_target, best_score, best_cth = target, mod, base_mod end end end end end if #potential_targets > 0 then local total = 0 for _, target in ipairs(potential_targets) do local score = target_score[target] total = total + score if debug_data then debug_data[target] = score end end local roll = InteractionRand(total, "AIDecision") for _, target in ipairs(potential_targets) do local score = target_score[target] if roll < score then best_target = target break end roll = roll - score end best_target = best_target or potential_targets[#potential_targets] or false best_score = target_score[best_target] or 0 best_cth = target_cth[best_target] or 0 end --[[ if destinations and IsKindOf(best_target, "Unit") then if best_target == preferred_target then printf("%s (%d) selected target (preferred): %s (score %d)", _InternalTranslate(unit.Name or ""), unit.handle, _InternalTranslate(best_target.Name or ""), best_score) else printf("%s (%d) selected target: %s (score %d)", _InternalTranslate(unit.Name or ""), unit.handle, _InternalTranslate(best_target.Name or ""), best_score) printf(" potential targets:") for _, target in ipairs(potential_targets) do printf(" %s (score %d)", _InternalTranslate(target.Name or ""), target_score[target]) end end end--]] --logdata.chosen_target = best_target and (IsKindOf(best_target, "Unit") and _InternalTranslate(best_target.Name or "") or best_target.class) or tostring(best_target) dest_target_score[upos] = best_score dest_target[upos] = best_target dest_cth[upos] = best_cth end end function AIScoreReachableVoxels(context, policies, opt_loc_weight, dest_score_details, cur_dest_preference) local unit = context.unit policies = table.ifilter(policies, function(idx, policy) return policy:MatchUnit(unit) end) unit.ai_end_turn_search = {} local total_dist = context.total_dist local dest_dist = context.dest_dist or empty_table local curr_dest = context.voxel_to_dest[context.unit_world_voxel] or context.voxel_to_dest[context.closest_free_pos] or context.unit_stance_pos local dist = dest_dist[curr_dest] or total_dist local score = -opt_loc_weight if (total_dist or 0) > 0 then score = MulDivRound(score, dist, total_dist) end local unit_voxels = {} local best_end_score = curr_dest and AIScoreDest(context, policies, curr_dest, context.unit_grid_voxel, score, unit_voxels) -- cache the best voxel on the way to optimal location to use as fallback if needed local best_dist_score, closest_dest local potential_dests, dest_scores = {curr_dest}, {best_end_score} for _, dest in ipairs(context.destinations) do total_dist = Max(total_dist or 0, dest_dist[dest] or 0) end for _, dest in ipairs(context.destinations) do local score = 0 local scores local dist = dest_dist[dest] or 100*guim local dist_score = 0 if total_dist and total_dist > 0 then dist_score = MulDivRound(100 - MulDivRound(100, dist, total_dist), opt_loc_weight, 100) end if dist_score > (best_dist_score or 0) then best_dist_score, closest_dest = dist_score, dest end score = score + dist_score if dest_score_details then scores = { "Distance to optimal location", dist_score } dest_score_details[dest] = scores end table.iclear(unit_voxels) score = AIScoreDest(context, policies, dest, nil, score, unit_voxels, scores) if MulDivRound(best_end_score or 0, const.AIDecisionThreshold, 100) <= score then best_end_score = Max(score, best_end_score or 0) local n = #potential_dests potential_dests[n+1] = dest dest_scores[n+1] = score local threshold = MulDivRound(best_end_score, const.AIDecisionThreshold, 100) -- updated threshold for i = n, 1, -1 do if dest_scores[i] < threshold then table.remove(dest_scores, i) table.remove(potential_dests, i) end end end if scores then scores.final_score = score end end -- pick best_end_dest/score from potential_dests assert(#potential_dests > 0) context.best_end_dest = false if cur_dest_preference == "prefer" then if table.find(potential_dests, curr_dest) then context.best_end_dest = curr_dest end elseif cur_dest_preference == "avoid" then if #potential_dests > 1 then table.remove_value(potential_dests, curr_dest) end end NetUpdateHash("AIScoreReachableVoxels", unit, unit:GetPos(), unit.ActionPoints, context.archetype.id, #(context.destinations or ""), hashParamTable(context.destinations), #(potential_dests or ""), hashParamTable(potential_dests), cur_dest_preference) if not context.best_end_dest then local total = 0 for _, score in ipairs(potential_dests) do total = total + score end local roll = InteractionRand(total, "AIDecision") for i, dest in ipairs(potential_dests) do local score = dest_scores[i] if score <= roll then context.best_end_dest = dest break end roll = roll - score end context.best_end_dest = context.best_end_dest or potential_dests[#potential_dests] or curr_dest end context.best_end_score = best_end_score context.closest_dest = closest_dest return context.best_end_dest, context.best_end_score end function CalcPathVoxels(path) local dist = 0 if not IsPoint(path[1]) then local pt_path = {} for i, ppos in ipairs(path) do pt_path[i] = point(point_unpack(ppos)) end path = pt_path end local processed_path = { path[1] } local voxel_dist = {} local voxels = {} voxel_dist[point_pack(path[1])] = 0 local function push_path_segment(seg_start, seg_end, path_dist, tunnel) local seg_dist = seg_start:Dist(seg_end) if not tunnel and seg_dist > const.SlabSizeX/2 then local midpt = (seg_start + seg_end) / 2 push_path_segment(seg_start, midpt, path_dist) push_path_segment(midpt, seg_end, path_dist + seg_dist / 2) else processed_path[#processed_path + 1] = seg_end local x, y, z = GetPassSlabXYZ(seg_end) local pck_end = x and point_pack(x, y, z) if pck_end and not voxel_dist[pck_end] then voxel_dist[pck_end] = path_dist + seg_dist voxels[#voxels + 1] = pck_end --[[ local pt = point(x, y, z) if not pt:IsValidZ() then pt = pt:SetTerrainZ() end DbgAddVector(pt, point(0, 0, guim), const.clrGreen) DbgAddText(tostring(path_dist + seg_dist), pt + point(0, 0, guim/2), const.clrWhite)--]] end end return seg_dist end local dist = 0 local marker = InvalidPos() local seg_start_idx, seg_end_idx --DbgClearVectors() --DbgClearTexts() for i = 1, #path do if not seg_start_idx then seg_start_idx = path[i] ~= marker and i elseif not seg_end_idx then seg_end_idx = path[i] ~= marker and i end if seg_start_idx and seg_end_idx then --[[ local pt1 = path[seg_end_idx] local pt2 = path[seg_start_idx] if not pt1:IsValidZ() or pt1:z() < terrain.GetHeight(pt1) + 50*guic then pt1 = pt1:SetTerrainZ(100*guic) end if not pt2:IsValidZ() or pt2:z() < terrain.GetHeight(pt2) + 50*guic then pt2 = pt2:SetTerrainZ(100*guic) end DbgAddVector(pt1, point(0, 0, guim), const.clrWhite) DbgAddVector(pt2, point(0, 0, guim), const.clrWhite) printf("seg %d: %s - %s", seg_start_idx, tostring(pt2), tostring(pt1)) DbgAddVector(pt1, pt2 - pt1, seg_end_idx > seg_start_idx + 1 and const.clrYellow or const.clrWhite) DbgAddText(tostring(seg_start_idx), (pt1+pt2)/2, const.clrBlue) --]] dist = dist + push_path_segment(path[seg_start_idx], path[seg_end_idx], dist, seg_end_idx > seg_start_idx + 1) seg_start_idx = seg_end_idx seg_end_idx = false end end return voxels, voxel_dist, dist end function AICalcDistancesFromReachableLocations(context) local voxel_idx = 1 local stance = context.archetype.MoveStance local tunnel_mask = stance == "Prone" and const.TunnelTypeWalk or -1 local processed = {} local voxel_to_dest = context.voxel_to_dest local path_voxels = context.path_voxels local voxel_dist = context.voxel_dist local dest_dist = {} context.dest_dist = dest_dist for voxel, dist in pairs(context.voxel_dist) do local dest = voxel_to_dest[voxel] if dest then context.dest_dist[dest] = dist end end --DbgClearVectors() --DbgClearTexts() while path_voxels[voxel_idx] do local voxel = path_voxels[voxel_idx] local dest = voxel_to_dest[voxel] if not processed[voxel] then processed[voxel] = true local px, py, pz = point_unpack(voxel) --[[ local pt = point(px, py, pz) if not pt:IsValidZ() then pt = pt:SetTerrainZ() end DbgAddVector(pt, point(0, 0, 2*guim), const.clrBlue) DbgAddText(dest and dest_dist[dest] and tostring(dest_dist[dest]) or "n/a", pt + point(0, 0, guim), const.clrWhite) DbgAddText(voxel_dist[voxel] and tostring(voxel_dist[voxel]) or "n/a", pt + point(0, 0, guim/2), const.clrYellow) --]] ForEachPassSlabStep(px, py, pz, tunnel_mask, function(x, y, z, tunnel) local curr_voxel = point_pack(x, y, z) local curr_dest = voxel_to_dest[curr_voxel] if curr_dest and dest then assert(voxel_dist[voxel]) local x2, y2, z2 = point_unpack(voxel) local dx, dy, dz = x - x2, y - y2, (z and z2) and z - z2 or 0 local dist = voxel_dist[voxel] + sqrt(dx*dx + dy*dy + dz*dz) -- the tile is guaranteed to be reachable, so we can take linear distance if not voxel_dist[curr_voxel] or voxel_dist[curr_voxel] > dist then -- if the step is a tunnel, we need to check if it goes both ways to filter out shortcuts from target to current location if not tunnel or pf.GetTunnel(tunnel.end_point, tunnel:GetPos()) then voxel_dist[curr_voxel] = dist dest_dist[curr_dest] = dist end end path_voxels[#path_voxels + 1] = curr_voxel if not voxel_dist[curr_voxel] then voxel_dist[curr_voxel] = dist end if not dest_dist[curr_dest] then dest_dist[curr_dest] = dist end end end) end voxel_idx = voxel_idx + 1 end end function AIGetAttackArgs(context, action, target_spot_group, aim_type, override_target) local upos = GetPackedPosAndStance(context.unit) local target = override_target or context.dest_target[upos] local args = { target = target, target_spot_group = target_spot_group or "Torso" } local dest_ap if context.ai_destination then local u_x, u_y, u_z = stance_pos_unpack(upos) local dest_x, dest_y, dest_z = stance_pos_unpack(context.ai_destination) if point(u_x, u_y, u_z) ~= point(dest_x, dest_y, dest_z) then dest_ap = context.dest_ap[context.ai_destination] end end local unit_ap = dest_ap or context.unit:GetUIActionPoints() if action.id == "Overwatch" then local attacks, aim = context.unit:GetOverwatchAttacksAndAim(action, args, unit_ap) args.num_attacks = attacks args.aim_ap = aim elseif aim_type ~= "None" then args.aim = context.weapon.MaxAimActions if aim_type == "Remaining AP" then while args.aim > 0 and not context.unit:HasAP(action:GetAPCost(context.unit, args)) do args.aim = args.aim - 1 end end end local cost = action:GetAPCost(context.unit, args) local has_ap = cost >= 0 and (unit_ap >= cost) return args, has_ap, target end function AIFilterTargetPoints(unit, target_pts, min_range, max_range) for i = #target_pts, 1, -1 do local dist = unit:GetDist(target_pts[i]) if dist == 0 or (max_range and dist > max_range) then table.remove(target_pts, i) elseif min_range and min_range < max_range and dist < min_range then table.remove(target_pts, i) end end end function AICalcAOETargetPoints(context, min_range, max_range, max_radius) local target_pts = {} local unit = context.unit local enemies = context.enemies -- add enemy positions for i, enemy in ipairs(enemies) do if VisibilityCheckAll(unit, enemy, nil, const.uvVisible) then target_pts[#target_pts + 1] = context.enemy_pos[enemy] end end local num_targets = #target_pts -- add midpoints of enemy pairs for i = 1, num_targets - 1 do for j = i + 1, num_targets do local pt = (target_pts[i] + target_pts[j]) / 2 if not max_radius or pt:Dist(target_pts[i]) <= max_radius then target_pts[#target_pts + 1] = pt end end end -- add midpoints of enemy triples for i = 1, num_targets - 2 do for j = i + 1, num_targets - 1 do for k = j + 1, num_targets do local pt = (target_pts[i] + target_pts[j] + target_pts[k]) / 3 if not max_radius or pt:Dist(target_pts[i]) <= max_radius then target_pts[#target_pts + 1] = pt end end end end -- filter out target points not in range AIFilterTargetPoints(unit, target_pts, min_range, max_range) return target_pts end function AIPrecalcConeTargetZones(context, action_id, additional_target_pt, stance) if context.target_locked then return {} end local unit = context.unit local weapon = context.weapon local params = weapon:GetAreaAttackParams(action_id, unit) local min_range = params.min_range * const.SlabSizeX local max_range = params.max_range * const.SlabSizeX local target_pts = AICalcAOETargetPoints(context, min_range, max_range) if additional_target_pt then target_pts[#target_pts + 1] = additional_target_pt end -- calc cone areas for each remaining target point local zones = {} local cone_angle = params.cone_angle local targets = {} local attack_pos = unit:GetPos() -- make sure we're using the current position in case the unit has moved local units = table.copy(context.enemies) table.iappend(units, GetAllAlliedUnits(unit)) local unit_sight = unit:GetSightRadius() for zi, pt in ipairs(target_pts) do local dir = pt - attack_pos if dir:Len() > 0 then local target_pos = (attack_pos + SetLen(dir, max_range)):SetTerrainZ() local zone = { target_pos = target_pos, units = {}, } zones[#zones + 1] = zone local angle = CalcOrientation(attack_pos, pt) local los_any, los_targets = CheckLOS(units, unit, unit:GetDist(target_pos), nil, cone_angle, angle) if los_any then for i, target_unit in ipairs(units) do if los_targets[i] and IsValidTarget(target_unit) then zone.units[#zone.units + 1] = target_unit table.insert_unique(targets, target_unit) end end end end end local check_ally if action_id == "Overwatch" then local atk_action = context.default_attack local aim_type = atk_action.AimType local is_aoe = aim_type == "cone" or aim_type == "aoe" or aim_type == "parabola aoe" or aim_type == "line aoe" check_ally = not is_aoe end -- filter LOS targets local max_distance = Min(unit_sight, weapon:GetMaxRange()) local los_any, los_targets = CheckLOS(targets, unit, max_distance) if not los_any then for _, zone in ipairs(zones) do table.iclear(zone.units) end return zones end for i = #targets, 1, -1 do if not los_any or not los_targets[i] then for _, zone in ipairs(zones) do table.remove_value(zone.units, targets[i]) end table.remove(targets, i) end end -- check chance to hit local targets_attack_data = GetLoFData(unit, targets, { obj = unit, action_id = context.default_attack.id, weapon = weapon, stance = unit.stance, range = max_distance, target_spot_group = "Torso", prediction = true, }) local action = CombatActions[action_id] local args = { target_spot_group = false } for i, attack_data in ipairs(targets_attack_data) do local target = targets[i] local chance_to_hit = 0 if attack_data and not attack_data.stuck then for j, hit_info in ipairs(attack_data.lof) do if not check_ally or hit_info.ally_hits_count == 0 then args.target_spot_group = hit_info.target_spot_group chance_to_hit = unit:CalcChanceToHit(target, action, args, "chance_only") if chance_to_hit > 0 then break end end end end if chance_to_hit == 0 then for _, zone in ipairs(zones) do table.remove_value(zone.units, target) end end end return zones end local function IsUnitHit(hit) if not IsKindOf(hit.obj, "Unit") then return false end if hit.damage > 0 then return true end for _, effect in ipairs(hit.effects) do if effect and effect ~= "" then return true end end end function AIPrecalcGrenadeZones(context, action_id, min_range, max_range, blast_radius, aoeType, target_pts) if context.target_locked then return {} end if not target_pts then target_pts = AICalcAOETargetPoints(context, min_range, max_range, blast_radius) else -- make sure the target points are within the allowed range AIFilterTargetPoints(context.unit, target_pts, min_range, max_range) end -- calculate parabolas and affected units to each target point local zones = {} local action = CombatActions[action_id] local args = { target = false } for i, target_pt in ipairs(target_pts) do args.target = target_pt local results = action:GetActionResults(context.unit, args) local units local trajectory = results.trajectory or empty_table local pos = #trajectory > 0 and trajectory[#trajectory].pos or results.target_pos if pos and (aoeType == "smoke" or aoeType == "toxicgas" or aoeType == "teargas") then local water = terrain.IsWater(pos) and terrain.GetWaterHeight(pos) if not (water and (not pos:IsValidZ() or water >= pos:z())) then pos = SnapToPassSlab(pos) or pos local dx, dy = 1, 1 for i = #trajectory - 1, 1, -1 do local step = trajectory[i] if step.pos:Dist2D(pos) > 0 then local px, py = step.pos:xy() local x, y = pos:xy() dx = (px == x) and 1 or ((x - px) / abs(x - px)) dy = (py == y) and 1 or ((y - py) / abs(y - py)) break end end local gx, gy, gz = WorldToVoxel(pos) local smoke, blocked = PropagateSmokeInGrid(gx, gy, gz, dx, dy) local smoke_voxels = {} for _, wpt in pairs(smoke) do local ppos = point_pack(WorldToVoxel(wpt)) smoke_voxels[ppos] = true end for _, unit in ipairs(g_Units) do local _, head = unit:GetVisualVoxels() if smoke_voxels[head] then units = units or {} table.insert(units, unit) end end end else for _, hit in ipairs(results) do if IsUnitHit(hit) then units = units or {} table.insert(units, hit.obj) end end end if units then zones[#zones + 1] = { target_pos = target_pt, units = units } end end --print("grenade targeting precalc in", GetPreciseTicks() - tstart, "ms") return zones end function AIPrecalcLandmineZones(context) if context.target_locked then return {} end local weapon = context.weapon if not IsKindOf(weapon, "Firearm") then return {} end if not context.mine_zones then local unit = context.unit local sight = unit:GetSightRadius() local max_range = Min(weapon.WeaponRange * const.SlabSizeX, sight) local landmines = MapGet(unit, max_range, "Landmine", function(o, unit) return o:SeenBy(unit) end, unit) local zones = {} for _, mine in ipairs(landmines) do local aoe_params = mine:GetAreaAttackParams(nil, unit, mine:GetPos()) aoe_params.prediction = true local results = GetAreaAttackResults(aoe_params, 0) local units for _, hit in ipairs(results) do if IsKindOf(hit.obj, "Unit") and hit.damage > 0 then if not units then units = {} end table.insert(units, hit.obj) end end if units then zones[#zones + 1] = { target = mine, units = units } end end context.mine_zones = zones end return context.mine_zones end function AISelectHealTarget(context, dest, grid_voxel, heal_policy) if context.voxel_heal_score[grid_voxel] then return context.voxel_heal_target[grid_voxel], context.voxel_heal_score[grid_voxel] end local x, y, z = point_unpack(grid_voxel) local best_target, best_score = false, 0 local dx, dy, dz = stance_pos_unpack(dest) local ppos = point_pack(dx, dy, dz) for _, ally in ipairs(context.allies) do local hpp = MulDivRound(ally.HitPoints, 100, ally.MaxHitPoints) local score if hpp <= heal_policy.MaxHp and not ally:IsDead() then local bleed = 0 if ally:HasStatusEffect("Bleeding") then bleed = heal_policy.BleedingWeight end local gx, gy, gz = point_unpack(context.ally_grid_voxel[ally]) if ally == context.unit or IsMeleeRangeTarget(context.unit, ppos, nil, ally) then --(abs(x - gx) <= 1 and abs(y - gy) <= 1 and abs(z - gz) <= 1) then score = MulDivRound(100 - hpp, heal_policy.HpWeight, 100) + bleed end if ally == context.unit then score = MulDivRound(score, heal_policy.SelfHealMod, 100) end end score = score or 0 if not best_score or score > best_score then best_target, best_score = ally, score end end local ap_at_dest = context.dest_ap[dest] or 0 if ap_at_dest >= CombatActions.Bandage.ActionPoints then best_score = MulDivRound(best_score, heal_policy.CanUseMod, 100) end context.voxel_heal_target[grid_voxel] = best_target context.voxel_heal_score[grid_voxel] = best_score return best_target, best_score end function AIEvalStimTarget(unit, target, rules) if target:IsDead() or target:HasStatusEffect("Stimmed") then return 0 end local score = 0 for _, rule in ipairs(rules) do if table.find(target.AIKeywords or empty_table, rule.Keyword) then score = score + rule.Weight end end return score end local AITurnPhasePriority = { Early = 1, Normal = 2, Late = 3, } function AIGetNextPhaseUnits(units, max) local best_units, best_prio for _, unit in ipairs(units) do local behavior = unit.ai_context and unit.ai_context.behavior if behavior then local turn_phase = behavior:GetTurnPhase(unit) local prio = AITurnPhasePriority[turn_phase] or 999 if not best_prio or prio < best_prio then best_units, best_prio = {unit}, prio elseif prio == best_prio then best_units[#best_units + 1] = unit end if max and #(best_units or empty_table) >= max then break end end end return best_units end function IsMeleeRangeTarget(attacker, attack_pos, attack_stance, target, target_pos, target_stance, attacker_face_angle) if not IsValidTarget(target) then return end if IsSittingUnit(target) then target_pos = target_pos or target.last_visit:GetPos() target_stance = "Crouch" end return IsMeleeRangeTargetC(attacker, attack_pos, attack_stance, target, target_pos, target_stance, attacker_face_angle) end function GetMeleeRangePositions(attacker, target, target_pos, check_occupied) if IsSittingUnit(target) then target_pos = target.last_visit:GetPos() end return GetMeleeRangePositionsC(attacker, target, target_pos, check_occupied) end function GetClosestMeleeRangePos(attacker, target, target_pos, check_occupied) if IsSittingUnit(target) then target_pos = target.last_visit:GetPos() end return GetClosestMeleeRangePosC(attacker, target, target_pos, check_occupied) end function AIRangeCheck(context, ppt1, target, ppt2, range_type, range_min, range_max) if range_type == "Melee" then local p1 = point_pack(VoxelToWorld(point_unpack(ppt1))) local p2 = point_pack(VoxelToWorld(point_unpack(ppt2))) return IsMeleeRangeTarget(context.unit, p1, context.unit.stance, target, p2, target.stance) end if range_type ~= "Absolute" then -- weapon range based assert(range_type == "Weapon") local base_range = context.ExtremeRange range_min = range_min and MulDivRound(range_min, base_range, 100) range_max = range_max and MulDivRound(range_max, base_range, 100) end local x1, y1, z1 = point_unpack(ppt1) local x2, y2, z2 = point_unpack(ppt2) if (range_min or 0) > 0 and IsCloser(x1, y1, z1, x2, y2, z2, range_min) then return false end if (range_max or 0) > 0 and not IsCloser(x1, y1, z1, x2, y2, z2, range_max + 1) then return false end return true end function AIReloadWeapons(unit) if IsMerc(unit) then return end local firearms = select(3, unit:GetActiveWeapons("Firearm")) table.iappend(firearms, select(3, unit:GetActiveWeapons("HeavyWeapon"))) for _, firearm in ipairs(firearms) do if not firearm.ammo then local ammos = unit:GetAvailableAmmos(firearm) or empty_table local ammo if #ammos > 0 then ammo = ammos[1] ammo.Amount = Max(ammo.Amount, firearm.MagazineSize) unit:ReloadWeapon(firearm, ammo, "delay fx", "ai") CreateFloatingText(unit, T(160472488023, "Reload")) ObjModified(unit) else ammos = GetAmmosWithCaliber(firearm.Caliber, "sorted") if #ammos > 0 then ammo = PlaceInventoryItem(ammos[1].id) ammo.Amount = firearm.MagazineSize unit:ReloadWeapon(firearm, ammo, "delay fx", "ai") CreateFloatingText(unit, T(160472488023, "Reload")) DoneObject(ammo) ObjModified(unit) end end elseif firearm.ammo.Amount < Max(1, firearm.MagazineSize / 2) then local ammo = firearm.ammo ammo.Amount = firearm.MagazineSize unit:ReloadWeapon(firearm, ammo, "delay fx", "ai") CreateFloatingText(unit, T(160472488023, "Reload")) ObjModified(unit) end end end function AIPickScoutLocation(unit) local AIScoutLocationSearchRadius = 5 * guim -- pick a new position around alive enemy randomly, prefer non-hidden enemies local enemies = GetAllEnemyUnits(unit) if #enemies == 0 then return end local targets local nearest, nearby = {}, {} for _, enemy in ipairs(enemies) do local dist = unit:GetDist(enemy) if dist <= AIScoutLocationSearchRadius then nearest[#nearest + 1] = enemy targets = nearest elseif dist <= 2*AIScoutLocationSearchRadius then nearby[#nearby + 1] = enemy targets = targets or nearby end end targets = targets or enemies local enemy = table.interaction_rand(enemies, "Combat") local ux, uy, uz = enemy:GetGridCoords() local px, py, pz = VoxelToWorld(ux, uy, uz) local r = AIScoutLocationSearchRadius local bbox = box(px - r, py - r, 0, px + r + 1, py + r + 1, MapSlabsBBox_MaxZ) local dests, dest_added = {}, {} local function push_dest(x, y, z, dests, dest_added, ux, uy, uz) local gx, gy, gz = WorldToVoxel(x, y, z) if not IsCloser(gx, gy, gz, ux, uy, uz, AIScoutLocationSearchRadius) then return end local world_voxel = point_pack(x, y, z) if not dest_added[world_voxel] then dests[#dests + 1] = world_voxel dest_added[world_voxel] = true end end ForEachPassSlab(bbox, push_dest, dests, dest_added, ux, uy, uz) if #dests > 0 then local voxel = table.interaction_rand(dests, "Combat") local x, y, z = point_unpack(voxel) return point(x, y, z) end end function AIUpdateScoutLocation(unit) if not unit.last_known_enemy_pos then return end local sight = unit:GetSightRadius() if CheckLOS(unit.last_known_enemy_pos, unit, sight) then -- scouted here, next time pick a different location if still necessary unit.last_known_enemy_pos = nil end end MapVar("g_MGPriorityAssignment", {}) function AIAssignToEmplacements(team) local emplacements = MapGet("map", "MachineGunEmplacement") local units = table.ifilter(team.units, function(idx, unit) return unit.CanManEmplacements end) -- update emplacements' appeal for the team for _, emplacement in ipairs(emplacements) do local targets = #units > 0 and emplacement:GetEnemyUnitsInArea(units[1]) or empty_table local appeal = MulDivRound(emplacement.appeal[team.side] or 0, Max(0, 100 - emplacement.appeal_decay), 100) for _, enemy in ipairs(targets) do local dist = emplacement:GetDist(enemy) local diff = abs(dist - emplacement.appeal_optimal_dist) appeal = appeal + Max(0, emplacement.appeal_per_target + MulDivRound(emplacement.appeal_per_meter, dist, guim)) end emplacement.appeal[team.side] = appeal if not SpawnedByEnabledMarker(emplacement) or not emplacement.enabled then emplacement.appeal[team.side] = 0 end end if emplacements then table.sort(emplacements, function(a, b) return a.appeal[team.side] > b.appeal[team.side] end) end for _, emplacement in ipairs(emplacements) do local assigned_unit = g_Combat:GetEmplacementAssignment(emplacement) if (emplacement.appeal[team.side] or 0) > emplacement.appeal_use_threshold then if not emplacement.manned_by and not assigned_unit then -- free for grabs, find a unit to man the MG -- check priority assignment first local gunner for _, unit in ipairs(g_MGPriorityAssignment) do if IsValidTarget(unit) and unit.team == team and unit.CanManEmplacements and not unit:IsIncapacitated() then gunner = unit break end end if not gunner then local emplacement_pos = SnapToPassSlab(emplacement:GetPosXYZ()) if emplacement_pos then table.sort(units, function(a, b) return IsCloser(emplacement_pos, a, b) end) local closest, closest_pf_dist for _, u in ipairs(units) do -- select unit closest to the emplacement (by pathfind) local emp = g_Combat:GetEmplacementAssignment(u) if not emp and (not closest or IsCloser(u, emplacement_pos, closest_pf_dist)) then local has_path, path_len, closest_pos = pf.PosPathLen(u, emplacement_pos, nil, 0, 0, u, 0, nil, 0) if has_path and closest_pos == emplacement_pos then if not closest_pf_dist or path_len < closest_pf_dist then closest, closest_pf_dist = u, path_len end end end end gunner = closest end end if gunner then g_Combat:AssignEmplacement(emplacement, gunner) end elseif assigned_unit and assigned_unit.team == team then if emplacement.manned_by and emplacement.manned_by ~= assigned_unit then -- somebody else took it, clean up assignment g_Combat:AssignEmplacement(emplacement, nil) end end elseif assigned_unit and assigned_unit.team == team then g_Combat:AssignEmplacement(emplacement, nil) end end end function AIEnemyWeaponsCombo() local types = table.map(GetWeaponTypes(), "id") table.insert_unique(types, "Pistol") table.insert_unique(types, "Revolver") table.insert_unique(types, "MeleeWeapon") table.insert_unique(types, "Unarmed") return types end function measure_func(func, num_invocations, ...) num_invocations = num_invocations or 0 if num_invocations < 1 then return end local start = GetPreciseTicks() for i = 1, num_invocations do func(...) end local elapsed_ms = GetPreciseTicks() - start printf("%d invocations finished in %d ms for (%d ms average)", num_invocations, elapsed_ms, elapsed_ms / num_invocations) end DefineClass.AIBiasMarker = { __parents = { "GridMarker" }, properties = { { category = "AI Bias", id = "UnitGroups", name = "UnitGroups", editor = "string_list", default = false, items = function (self) return GetUnitGroups() end }, { category = "AI Bias", id = "Bias", editor = "number", min = 0, max = 1000, scale = "%", slider = true, default = 100, help = "modifier applied to AI evaluations of destinations inside the marker area"}, }, } function AIBiasMarker:GetAIBias(unit, dest) if not unit or not self:IsMarkerEnabled() then return 100 end local x, y, z = stance_pos_unpack(dest) z = z or terrain.GetHeight(x, y) x, y = WorldToVoxel(x, y, z) if not self:IsVoxelInsideArea2D(x, y) then return 100 end local apply_groups = g_BiasMarkers[self] or empty_table for _, group in ipairs(unit.Groups) do if apply_groups[group] then return self.Bias end end return 100 end function InitAIBiasMarkers() g_BiasMarkers = g_BiasMarkers or MapGetMarkers("GridMarker", nil, function(m) return IsKindOf(m, "AIBiasMarker") end) or false for _, marker in ipairs(g_BiasMarkers) do local apply_grous = {} g_BiasMarkers[marker] = apply_grous for _, group in ipairs(marker.UnitGroups) do apply_grous[group] = true end end end function AICheckIndoors(dest) if g_AIDestIndoorsCache[dest] == nil then local x, y, z = stance_pos_unpack(dest) local volume = EnumVolumes(point(x, y, z), "smallest") g_AIDestIndoorsCache[dest] = not not volume end return g_AIDestIndoorsCache[dest] end