packages feed

swarm-0.6.0.0: src/swarm-engine/Swarm/Game/Step/Path/Cache.hs

-- |
-- SPDX-License-Identifier: BSD-3-Clause
--
-- Pathfinding cache invalidation logic
--
-- == Overview
-- Each time the 'Path' command is invoked, the computed
-- shortest-path is placed in in a cache specific to the invoking robot.
-- If the 'Path' command is invoked again by that robot
-- with identical arguments and from the same position, or a position lying
-- on the previously computed path, then the shortest path shall
-- be retrieved from the cache instead of being recomputed.
--
-- If the 'Path' command is re-invoked with different arguments
-- or from a novel position, then the shortest-path shall be
-- recomputed and the cache overwritten with this new result.
--
-- Asynchronous to the event of invoking the 'Path' command,
-- there are a variety of events that may invalidate
-- a previously-computed shortest path between some
-- location and a destination, including adding or removing
-- particular entities at certain locations.
--
-- Certain events allow for partial re-use of the previously
-- computed path.
module Swarm.Game.Step.Path.Cache (
  retrieveCachedPath,
  revalidatePathCache,
  recordCache,
) where

import Control.Arrow (left, (&&&))
import Control.Carrier.State.Lazy
import Control.Effect.Lens
import Control.Lens ((^.))
import Control.Monad (unless)
import Data.Either.Extra (maybeToEither)
import Data.IntMap qualified as IM
import Data.List.NonEmpty (NonEmpty)
import Data.List.NonEmpty qualified as NE
import Data.Map qualified as M
import Data.Tuple.Extra (both)
import Swarm.Game.Entity
import Swarm.Game.Location
import Swarm.Game.Robot
import Swarm.Game.Robot.Walk
import Swarm.Game.State
import Swarm.Game.Step.Path.Cache.DistanceLimit
import Swarm.Game.Step.Path.Type
import Swarm.Game.Step.Path.Walkability (checkUnwalkable)
import Swarm.Game.Step.RobotStepState
import Swarm.Game.Step.Util.Inspect (robotWithID)
import Swarm.Game.Universe (Cosmic (..), SubworldName)
import Swarm.Game.World.Modify
import Swarm.Util (prependList, tails1)
import Swarm.Util.RingBuffer qualified as RB

-- | Fetch the previously computed shortest path from the cache.
-- Log success or the reason it failed.
retrieveCachedPath ::
  HasRobotStepState sig m =>
  WalkabilityContext ->
  PathfindingParameters (Cosmic Location) ->
  m (Either CacheRetreivalInapplicability [Location])
retrieveCachedPath currentWalkabilityContext newParms = do
  pcr <- use $ pathCaching . pathCachingRobots
  rid <- use robotID
  let eitherCachedPath = guardFailures rid pcr
      myEntry :: CacheRetrievalAttempt
      myEntry = either RecomputationRequired (const Success) eitherCachedPath

  pathCaching . pathCachingLog
    %= RB.insert (CacheLogEntry rid $ RetrievalAttempt myEntry)

  return eitherCachedPath
 where
  PathfindingParameters currentDistanceLimit (Cosmic currentSubworld currentRobotLoc) target = newParms

  guardFailures rid pcr = do
    -- Checks whether this robot has a cached path
    cached <- maybeToEither NotCached $ IM.lookup rid pcr

    let PathfindingCache prevParms prevWalkabilityContext _targetLoc (CachedPath pathCells (TailMap ps)) = cached
        PathfindingParameters prevDistLimit previousSubworldName t = prevParms

    -- Subworlds must match
    unless (previousSubworldName == currentSubworld) $
      Left $
        DifferentArg NewSubworld

    -- Pathfinding target type must match
    unless (t == target) $
      Left $
        DifferentArg NewTargetType

    -- Walkability context must match
    unless (currentWalkabilityContext == prevWalkabilityContext) $
      Left $
        DifferentArg NewWalkabilityContext

    left (DifferentArg . NewDistanceLimit) $
      getDistanceLimitInvalidation currentRobotLoc pathCells currentDistanceLimit prevDistLimit

    -- Checks whether invoked from the same position or a position lying
    -- on the previously computed path
    maybeToEither PositionOutsidePath $ M.lookup currentRobotLoc ps

-- | Store a newly computed shortest path in the cache.
recordCache ::
  HasRobotStepState sig m =>
  PathfindingParameters SubworldName ->
  WalkabilityContext ->
  -- | includes robot starting position
  NonEmpty Location ->
  m ()
recordCache parms wc pathLocs = do
  rid <- use robotID
  pathCaching . pathCachingRobots %= IM.insert rid newCache
 where
  newCache = PathfindingCache parms wc (NE.last pathLocs) $ CachedPath pathLocs $ mkTailMap pathLocs

-- | For every non-empty suffix of the path, place its tail in a map keyed
-- by its head.
mkTailMap :: NonEmpty Location -> TailMap
mkTailMap pathLocs = TailMap locsMap
 where
  locsMap = M.fromList . NE.toList . NE.map (NE.head &&& NE.tail) $ tails1 pathLocs

-- |
-- Returns either a 'Left' which mandates cache invalidation (with a reason),
-- or a 'Right' containing a 'Maybe'; 'Nothing' indicates the cache should
-- remain unchanged, while 'Just' supplies a modified cache entry.
--
-- Cache is affected by modification of:
--
-- * cell walkability (i.e., an entity is placed or removed
--   that is "unwalkable" (blacklist) or "exclusively walkable" (whitelist)
--   with respect to the invoking robot
-- * "target" entities (if the `path` command had been invoked
--   with the modified entity as a target). Note that it is impossible
--   to find a path to an "unwalkable" target, so this nonsensical case
--   is ignored for the purpose of cache invalidation.
perhapsInvalidateForRobot ::
  WalkabilityContext ->
  -- | location of modified cell
  Cosmic Location ->
  -- | nature of entity modification
  CellModification Entity ->
  PathfindingCache ->
  Either InvalidationReason (Maybe PathfindingCache)
perhapsInvalidateForRobot
  walkInfo
  (Cosmic swn entityLoc)
  entityModification
  oldCache@(PathfindingCache parms _previousWalkabilityInfo destLoc p)
    | swn /= pathSubworld = Right Nothing
    | otherwise = case entityModification of
        Swap oldEntity newEntity -> deriveBarrierModification $ both Just (oldEntity, newEntity)
        Remove oldEntity -> deriveBarrierModification (Just oldEntity, Nothing)
        Add newEntity -> deriveBarrierModification (Nothing, Just newEntity)
   where
    PathfindingParameters _distLimit pathSubworld tgt = parms
    CachedPath origPath (TailMap locmap) = p

    isWalkable = null . checkUnwalkable walkInfo
    isOnPath = entityLoc `M.member` locmap

    -- NOTE: oldContent and newContent are guaranteed to be different,
    -- because the 'Swap' constructor enforces such.
    deriveBarrierModification change@(_oldContent, newContent) =
      case tgt of
        LocationTarget _locTarget -> barrierChange
        -- If the location of the changed entity was the terminus
        -- of the path, and the path search is "by entity", then
        -- we know that the path must be invalidated due to removal
        -- of the goal.
        -- Also, we know that a "target entity" on the path will
        -- only ever exist the path's terminus; otherwise the
        -- terminus would have been earlier!
        EntityTarget targetEntityName -> handleEntityTarget targetEntityName
     where
      handleEntityTarget targetEntityName
        | destLoc == entityLoc = Left TargetEntityRemoved
        | maybe True ((/= targetEntityName) . (^. entityName)) newContent = barrierChange
        | isOnPath = Right $ Just $ truncatePath origPath entityLoc oldCache
        | otherwise = Left TargetEntityAddedOutsidePath

      walkabilityPair = both isWalkable change
      barrierChange
        | uncurry (==) walkabilityPair = Right Nothing
        | snd walkabilityPair = Left UnwalkableRemoved
        | isOnPath = Left UnwalkableOntoPath
        -- addition of a barrier outside of the path is irrelevant.
        | otherwise = Right Nothing

-- | If the newly-added target entity lies on the existing path,
-- truncate the path to set it as the goal.
truncatePath ::
  NonEmpty Location ->
  Location ->
  PathfindingCache ->
  PathfindingCache
truncatePath origPath entityLoc oldCache =
  oldCache {cachedPath = CachedPath truncPath $ mkTailMap truncPath}
 where
  truncPath = prependList truncPathExcludingEntityLoc $ pure entityLoc
  truncPathExcludingEntityLoc = takeWhile (== entityLoc) $ NE.toList origPath

-- | Given an event that entails the modification of some cell,
-- check whether a shortest-path previously computed for a
-- given robot is still valid or can be updated.
revalidatePathCache ::
  (Has (State GameState) sig m) =>
  Cosmic Location ->
  CellModification Entity ->
  (RID, PathfindingCache) ->
  m ()
revalidatePathCache entityLoc entityModification (rid, pc) = do
  maybeRobot <- robotWithID rid
  let (logEntry, updateFunc) = getCacheUpdate $ checkPath maybeRobot
  pathCaching . pathCachingRobots %= updateFunc
  pathCaching . pathCachingLog %= RB.insert (CacheLogEntry rid logEntry)
 where
  checkPath = \case
    Nothing -> Left NonexistentRobot
    Just bot ->
      perhapsInvalidateForRobot
        (view walkabilityContext bot)
        entityLoc
        entityModification
        pc

  getCacheUpdate = \case
    Left reason -> (Invalidate reason, IM.delete rid)
    Right maybeReplacement -> case maybeReplacement of
      Nothing -> (Preserve Unmodified, id)
      Just newCache -> (Preserve PathTruncated, IM.insert rid newCache)