{-# LANGUAGE TupleSections #-} -- | Let AI pick the best target for an actor. module Game.LambdaHack.Client.AI.PickTargetM ( refreshTarget #ifdef EXPOSE_INTERNAL -- * Internal operations , targetStrategy #endif ) where import Prelude () import Game.LambdaHack.Common.Prelude import qualified Data.EnumMap.Strict as EM import qualified Data.EnumSet as ES import Game.LambdaHack.Client.AI.ConditionM import Game.LambdaHack.Client.AI.Strategy import Game.LambdaHack.Client.Bfs import Game.LambdaHack.Client.BfsM import Game.LambdaHack.Client.CommonM import Game.LambdaHack.Client.MonadClient import Game.LambdaHack.Client.State import Game.LambdaHack.Common.Ability import Game.LambdaHack.Common.Actor import Game.LambdaHack.Common.ActorState import Game.LambdaHack.Common.Faction import Game.LambdaHack.Common.Frequency import Game.LambdaHack.Common.Item import qualified Game.LambdaHack.Common.Kind as Kind import Game.LambdaHack.Common.Level import Game.LambdaHack.Common.Misc import Game.LambdaHack.Common.MonadStateRead import Game.LambdaHack.Common.Point import qualified Game.LambdaHack.Common.PointArray as PointArray import Game.LambdaHack.Common.Random import Game.LambdaHack.Common.State import qualified Game.LambdaHack.Common.Tile as Tile import Game.LambdaHack.Common.Time import Game.LambdaHack.Common.Vector import Game.LambdaHack.Content.ModeKind import Game.LambdaHack.Content.RuleKind import Game.LambdaHack.Content.TileKind (isUknownSpace) -- | Verify and possibly change the target of an actor. This function both -- updates the target in the client state and returns the new target explicitly. refreshTarget :: MonadClient m => (ActorId, Actor) -> m (Maybe TgtAndPath) -- This inline speeds up execution by 6% and increases allocation by 9%, -- despite probably bloating executable (but it slows down execution -- if pickAI is not inlined): {-# INLINE refreshTarget #-} refreshTarget (aid, body) = do side <- getsClient sside let !_A = assert (bfid body == side `blame` "AI tries to move an enemy actor" `swith` (aid, body, side)) () let !_A = assert (isNothing (btrajectory body) && not (bproj body) `blame` "AI gets to manually move its projectiles" `swith` (aid, body, side)) () stratTarget <- targetStrategy aid if nullStrategy stratTarget then do -- Melee in progress and the actor can't contribute -- and would slow down others if he acted. modifyClient $ \cli -> cli {stargetD = EM.delete aid (stargetD cli)} return Nothing else do -- _debugoldTgt <- getsClient $ EM.lookup aid . stargetD -- Choose a target from those proposed by AI for the actor. tgtMPath <- rndToAction $ frequency $ bestVariant stratTarget modifyClient $ \cli -> cli {stargetD = EM.insert aid tgtMPath (stargetD cli)} return $ Just tgtMPath -- let _debug = T.unpack -- $ "\nHandleAI symbol:" <+> tshow (bsymbol body) -- <> ", aid:" <+> tshow aid -- <> ", pos:" <+> tshow (bpos body) -- <> "\nHandleAI oldTgt:" <+> tshow _debugoldTgt -- <> "\nHandleAI strTgt:" <+> tshow stratTarget -- <> "\nHandleAI target:" <+> tshow tgtMPath -- trace _debug $ return $ Just tgtMPath -- | AI proposes possible targets for the actor. Never empty. targetStrategy :: forall m. MonadClient m => ActorId -> m (Strategy TgtAndPath) {-# INLINE targetStrategy #-} targetStrategy aid = do Kind.COps{corule, coTileSpeedup} <- getsState scops b <- getsState $ getActorBody aid mleader <- getsClient sleader scondInMelee <- getsClient scondInMelee salter <- getsClient salter -- We assume the actor eventually becomes a leader (or has the same -- set of abilities as the leader, anyway) and set his target accordingly. actorAspect <- getsState sactorAspect let lalter = salter EM.! blid b condInMelee = fromMaybe (error $ "" `showFailure` condInMelee) (scondInMelee EM.! blid b) stdRuleset = Kind.stdRuleset corule nearby = rnearby stdRuleset ar = fromMaybe (error $ "" `showFailure` aid) (EM.lookup aid actorAspect) actorMaxSk = aSkills ar alterSkill = EM.findWithDefault 0 AbAlter actorMaxSk lvl@Level{lxsize, lysize} <- getLevel $ blid b let stepAccesible :: AndPath -> Bool stepAccesible AndPath{pathList=q : _} = -- Effectively, only @alterMinWalk@ is checked, because real altering -- is not done via target path, but action after end of path. alterSkill >= fromEnum (lalter PointArray.! q) stepAccesible _ = False mtgtMPath <- getsClient $ EM.lookup aid . stargetD oldTgtUpdatedPath <- case mtgtMPath of Just TgtAndPath{tapTgt,tapPath=NoPath} -> -- This case is especially for TEnemyPos that would be lost otherwise. -- This is also triggered by @UpdLeadFaction@. Just <$> createPath aid tapTgt Just tap@TgtAndPath{..} -> do mvalidPos <- aidTgtToPos aid (blid b) tapTgt if | isNothing mvalidPos -> return Nothing -- wrong level | bpos b == pathGoal tapPath -> return mtgtMPath -- goal reached; stay there picking up items | otherwise -> return $! case tapPath of AndPath{pathList=q : rest,..} -> case chessDist (bpos b) q of 0 -> -- step along path let newPath = AndPath{ pathList = rest , pathGoal , pathLen = pathLen - 1 } in if stepAccesible newPath then Just tap{tapPath=newPath} else Nothing 1 -> -- no move or a sidestep last turn if stepAccesible tapPath then mtgtMPath else Nothing _ -> Nothing -- veered off the path AndPath{pathList=[],..}-> Nothing -- path to the goal was partial; let's target again NoPath -> error $ "" `showFailure` tap Nothing -> return Nothing -- no target assigned yet fact <- getsState $ (EM.! bfid b) . sfactionD allFoes <- getsState $ actorRegularAssocs (isAtWar fact) (blid b) dungeon <- getsState sdungeon let canMove = EM.findWithDefault 0 AbMove actorMaxSk > 0 || EM.findWithDefault 0 AbDisplace actorMaxSk > 0 -- Needed for now, because AI targets and shoots enemies -- based on the path to them, not LOS to them: || EM.findWithDefault 0 AbProject actorMaxSk > 0 actorMinSk <- getsState $ actorSkills Nothing aid condCanProject <- condCanProjectM (EM.findWithDefault 0 AbProject actorMaxSk) aid condEnoughGear <- condEnoughGearM aid let condCanMelee = actorCanMelee actorAspect aid b condHpTooLow = hpTooLow b ar friends <- getsState $ friendlyActorRegularList (bfid b) (blid b) let canEscape = fcanEscape (gplayer fact) canSmell = aSmell ar > 0 meleeNearby | canEscape = nearby `div` 2 | otherwise = nearby rangedNearby = 2 * meleeNearby -- Don't melee-target nonmoving actors, unless they attack ours, -- because nonmoving can't be lured nor ambushed nor can't chase. -- This is especially important for fences, tower defense actors, etc. -- If content gives nonmoving actor loot, this becomes problematic. targetableMelee aidE body = do actorMaxSkE <- maxActorSkillsClient aidE let attacksFriends = any (adjacent (bpos body) . bpos) friends -- 3 is -- 1 from condSupport1 -- + 2 from foe being 2 away from friend before he closed in -- + 1 for as a margin for ambush, given than actors exploring -- can't physically keep adjacent all the time n | aAggression ar >= 2 = rangedNearby -- boss never waits | condInMelee = if attacksFriends then 4 else 0 | otherwise = meleeNearby nonmoving = EM.findWithDefault 0 AbMove actorMaxSkE <= 0 return {-keep lazy-} $ case chessDist (bpos body) (bpos b) of 1 -> True -- if adjacent, target even if can't melee, to flee cd -> condCanMelee && cd <= n && (not nonmoving || attacksFriends) -- Even when missiles run out, the non-moving foe will still be -- targeted, which is fine, since he is weakened by ranged, so should be -- meleed ASAP, even if without friends. targetableRanged body = (not condInMelee || aAggression ar >= 2) -- boss fires at will && chessDist (bpos body) (bpos b) < rangedNearby && condCanProject targetableEnemy (aidE, body) = do tMelee <- targetableMelee aidE body return $! targetableRanged body || tMelee nearbyFoes <- filterM targetableEnemy allFoes explored <- getsClient sexplored isStairPos <- getsState $ \s lid p -> isStair lid p s discoBenefit <- getsClient sdiscoBenefit s <- getState let lidExplored = ES.member (blid b) explored desirableBagFloor bag = any (\iid -> let item = getItemBody iid s benPick = benPickup <$> EM.lookup iid discoBenefit in desirableItem canEscape benPick item) $ EM.keys bag desirableFloor (_, (_, bag)) = desirableBagFloor bag focused = bspeed b ar < speedWalk || condHpTooLow couldMoveLastTurn = let actorSk = if mleader == Just aid then actorMaxSk else actorMinSk in EM.findWithDefault 0 AbMove actorSk > 0 isStuck = waitedLastTurn b && couldMoveLastTurn slackTactic = ftactic (gplayer fact) `elem` [TMeleeAndRanged, TMeleeAdjacent, TBlock, TRoam, TPatrol] setPath :: Target -> m (Strategy TgtAndPath) setPath tgt = do let take7 tap@TgtAndPath{tapTgt=TEnemy{}} = tap -- @TEnemy@ needed for projecting, even by roaming actors take7 tap@TgtAndPath{tapTgt,tapPath=AndPath{..}} = if slackTactic then -- Best path only followed 7 moves; then straight on. Cheaper. let path7 = take 7 pathList vtgt | bpos b == pathGoal = tapTgt -- goal reached | otherwise = TVector $ towards (bpos b) pathGoal in TgtAndPath{tapTgt=vtgt, tapPath=AndPath{pathList=path7, ..}} else tap take7 tap = tap tgtpath <- createPath aid tgt return $! returN "setPath" $ take7 tgtpath pickNewTarget :: m (Strategy TgtAndPath) pickNewTarget = do cfoes <- closestFoes nearbyFoes aid case cfoes of (_, (aid2, _)) : _ -> setPath $ TEnemy aid2 False [] | condInMelee -> return reject -- don't slow down fighters -- this looks a bit strange, because teammates stop in their tracks -- all around the map (unless very close to the combatant), -- but the intuition is, not being able to help immediately, -- and not being too friendly to each other, they just wait and see -- and also shout to the teammate to flee and lure foes into ambush [] -> do -- Tracking enemies is more important than exploring, -- and smelling actors are usually blind, so bad at exploring. smpos <- if canSmell then closestSmell aid else return [] case smpos of [] -> do citemsRaw <- closestItems aid let citems = toFreq "closestItems" $ filter desirableFloor citemsRaw if nullFreq citems then do -- This is mostly lazy and referred to a few times below. ctriggersRaw <- closestTriggers ViaAnything aid let ctriggers = toFreq "closestTriggers" ctriggersRaw if nullFreq ctriggers then do let vToTgt v0 = do let vFreq = toFreq "vFreq" $ (20, v0) : map (1,) moves v <- rndToAction $ frequency vFreq -- Items and smells, etc. considered every 7 moves. let pathSource = bpos b tra = trajectoryToPathBounded lxsize lysize pathSource (replicate 7 v) pathList = nub tra pathGoal = last pathList pathLen = length pathList return $! returN "tgt with no exploration" TgtAndPath { tapTgt = TVector v , tapPath = if pathLen == 0 then NoPath else AndPath{..} } oldpos = fromMaybe originPoint (boldpos b) vOld = bpos b `vectorToFrom` oldpos pNew = shiftBounded lxsize lysize (bpos b) vOld if slackTactic && not isStuck && isUnit vOld && bpos b /= pNew && Tile.isWalkable coTileSpeedup (lvl `at` pNew) -- if initial altering, consider carefully below then vToTgt vOld else do upos <- if lidExplored then return Nothing else closestUnknown aid -- modifies sexplored case upos of Nothing -> do explored2 <- getsClient sexplored let allExplored2 = ES.size explored2 == EM.size dungeon if allExplored2 || nullFreq ctriggers then do -- All stones turned, time to win or die. afoes <- closestFoes allFoes aid case afoes of (_, (aid2, _)) : _ -> setPath $ TEnemy aid2 False [] -> if nullFreq ctriggers then do furthest <- furthestKnown aid setPath $ TPoint TKnown (blid b) furthest else do (p, (p0, bag)) <- rndToAction $ frequency ctriggers setPath $ TPoint (TEmbed bag p0) (blid b) p else do (p, (p0, bag)) <- rndToAction $ frequency ctriggers setPath $ TPoint (TEmbed bag p0) (blid b) p Just p -> setPath $ TPoint TUnknown (blid b) p else do (p, (p0, bag)) <- rndToAction $ frequency ctriggers setPath $ TPoint (TEmbed bag p0) (blid b) p else do (p, bag) <- rndToAction $ frequency citems setPath $ TPoint (TItem bag) (blid b) p (_, (p, _)) : _ -> setPath $ TPoint TSmell (blid b) p tellOthersNothingHere pos = do let f TgtAndPath{tapTgt} = case tapTgt of TPoint _ lid p -> p /= pos || lid /= blid b _ -> True modifyClient $ \cli -> cli {stargetD = EM.filter f (stargetD cli)} pickNewTarget tileAdj :: (Point -> Bool) -> Point -> Bool tileAdj f p = any f $ vicinityUnsafe p followingWrong permit = permit && (condInMelee -- in melee, stop following || mleader == Just aid) -- a leader, never follow updateTgt :: TgtAndPath -> m (Strategy TgtAndPath) updateTgt TgtAndPath{tapPath=NoPath} = pickNewTarget updateTgt tap@TgtAndPath{tapPath=AndPath{..},tapTgt} = case tapTgt of TEnemy a permit -> do body <- getsState $ getActorBody a if | (condInMelee -- fight close foes or nobody at all || not focused && not (null nearbyFoes)) -- prefers closer foes && a `notElem` map fst nearbyFoes -- old one not close enough || blid body /= blid b -- wrong level || actorDying body -> -- foe already dying pickNewTarget | followingWrong permit -> pickNewTarget | bpos body == pathGoal -> return $! returN "TEnemy" tap -- The enemy didn't move since the target acquired. -- If any walls were added that make the enemy -- unreachable, AI learns that the hard way, -- as soon as it bumps into them. | otherwise -> do -- If there are no unwalkable tiles on the path to enemy, -- he gets target @TEnemy@ and then, even if such tiles emerge, -- the target updated by his moves remains @TEnemy@. -- Conversely, he is stuck with @TKnown@ if initial target had -- unwalkable tiles, for as long as they remain. Harmless quirk. mpath <- getCachePath aid $ bpos body case mpath of NoPath -> pickNewTarget -- enemy became unreachable AndPath{pathLen=0} -> pickNewTarget -- he is his own enemy AndPath{} -> return $! returN "TEnemy" tap{tapPath=mpath} -- In this case, need to retarget, to focus on foes that melee ours -- and not, e.g., on remembered foes or items. _ | condInMelee -> pickNewTarget TPoint _ lid _ | lid /= blid b -> pickNewTarget -- wrong level TPoint tgoal lid pos -> case tgoal of _ | not $ null nearbyFoes -> pickNewTarget -- prefer close foes to anything else TEnemyPos _ permit -- chase last position even if foe hides | bpos b == pos -> tellOthersNothingHere pos | followingWrong permit -> pickNewTarget | otherwise -> return $! returN "TEnemyPos" tap -- Below we check the target could not be picked again in -- pickNewTarget (e.g., an item got picked up by our teammate) -- and only in this case it is invalidated. -- This ensures targets are eventually reached (unless a foe -- shows up) and not changed all the time mid-route -- to equally interesting, but perhaps a bit closer targets, -- most probably already targeted by other actors. TEmbed bag p -> assert (adjacent pos p) $ do -- First, stairs and embedded items from @closestTriggers@. -- We don't check skills, because they normally don't change -- or we can put some equipment back and recover them. -- We don't determine if the stairs or embed are interesting -- (this changes with time), but allow the actor -- to reach them and then retarget. The two things we check -- is whether the embedded bag is still there, or used up -- and whether we happen to be already adjacent to @p@, -- even though not necessarily at @pos@. bag2 <- getsState $ getEmbedBag lid p -- not @pos@ if | bag /= bag2 -> pickNewTarget -- others will notice soon enough | adjacent (bpos b) p -> -- regardless if at @pos@ or not setPath $ TPoint TAny lid (bpos b) -- stay there one turn (high chance to become leader) -- to enable triggering; if trigger fails -- (e.g, changed skills), will retarget next turn (@TAny@) | otherwise -> return $! returN "TEmbed" tap TItem bag -> do bag2 <- getsState $ getFloorBag lid pos if | bag /= bag2 -> pickNewTarget -- others will notice soon enough | bpos b == pos -> setPath $ TPoint TAny lid (bpos b) -- stay there one turn (high chance to become leader) -- to enable pickup; if pickup fails, will retarget | otherwise -> return $! returN "TItem" tap TSmell -> let lvl2 = sdungeon s EM.! lid in if not canSmell || let sml = EM.findWithDefault timeZero pos (lsmell lvl2) in sml <= ltime lvl2 then pickNewTarget -- others will notice soon enough else return $! returN "TSmell" tap TUnknown -> let lvl2 = sdungeon s EM.! lid t = lvl2 `at` pos in if lidExplored || not (isUknownSpace t) || condEnoughGear && tileAdj (isStairPos lid) pos -- the unknown may be on the other side of the level -- and getting there only to explore 1 tile and get back -- looks silly then pickNewTarget -- others will notice soon enough else return $! returN "TUnknown" tap TKnown -> -- e.g., staircase or first unknown tile of an area let allExplored = ES.size explored == EM.size dungeon lvl2 = sdungeon s EM.! lid in if bpos b == pos || isStuck || alterSkill < fromEnum (lalter PointArray.! pos) -- tile was searched or altered or skill lowered || Tile.isWalkable coTileSpeedup (lvl2 `at` pos) && not allExplored -- not patrolling explored dungeon -- tile is no longer unwalkable, so was explored -- so time to recalculate target then pickNewTarget -- others unconcerned else return $! returN "TKnown" tap TAny -> pickNewTarget -- reset elsewhere or carried over from UI TVector{} -> if pathLen > 1 then return $! returN "TVector" tap else pickNewTarget if canMove then case oldTgtUpdatedPath of Nothing -> pickNewTarget Just tap -> updateTgt tap else return $! returN "NoMove" $ TgtAndPath (TEnemy aid True) NoPath