swarm-0.6.0.0: src/swarm-engine/Swarm/Game/Step/Path/Finding.hs
-- |
-- SPDX-License-Identifier: BSD-3-Clause
--
-- Implementation of the 'Swarm.Language.Syntax.Path' command for robots.
--
-- = Design considerations
-- In general the playfield can be dynamic, and obstructions may
-- appear that invalidate a given computed shortest path.
-- Therefore, there would be limited value in a command that returns
-- an entirely static computed path that is somehow stored on the client side
-- (i.e. inside a swarm-lang program).
--
-- In the current implementation, a complete path is computed
-- internally upon invoking the @path@ command
-- and doled out incrementally across ticks.
-- Each @path@ invocation returns the direction of the
-- next "move" along the computed shortest path.
--
-- This internally stored path is re-used across invocations until some
-- event invalidates its cache (see "Swarm.Game.Step.Path.Cache").
--
-- == Max distance
--
-- We allow the caller to supply a max distance, but also impose an internal maximum
-- distance to prevent programming errors from irrecoverably freezing the game.
module Swarm.Game.Step.Path.Finding where
import Control.Carrier.State.Lazy
import Control.Effect.Lens
import Control.Lens ((^.))
import Control.Monad (filterM, guard)
import Control.Monad.Trans.Class (lift)
import Control.Monad.Trans.Maybe (MaybeT (..), runMaybeT)
import Data.Graph.AStar (aStarM)
import Data.HashSet (HashSet)
import Data.HashSet qualified as HashSet
import Data.Int (Int32)
import Data.List.NonEmpty (NonEmpty ((:|)))
import Swarm.Game.Entity
import Swarm.Game.Location
import Swarm.Game.Robot
import Swarm.Game.State
import Swarm.Game.Step.Path.Cache
import Swarm.Game.Step.Path.Cache.DistanceLimit (withinDistance)
import Swarm.Game.Step.Path.Type
import Swarm.Game.Step.RobotStepState
import Swarm.Game.Step.Util
import Swarm.Game.Step.Util.Inspect
import Swarm.Game.Universe
import Swarm.Language.Syntax
import Swarm.Language.Syntax.Direction
import Swarm.Util (hoistMaybe)
-- | Swarm command arguments are converted to idiomatic Haskell
-- types before invoking this function, and conversely the callsite
-- is also responsible for translating the output type to a swarm value.
--
-- The cost function is uniformly @1@ between adjacent cells.
--
-- Viable paths are determined by walkability.
-- If the goal type is an 'Entity', then it is permissible for that
-- entity to be 'Unwalkable'.
--
-- See "Swarm.Game.Step.Path.Cache" for caching details.
pathCommand ::
HasRobotStepState sig m =>
PathfindingParameters (Cosmic Location) ->
m (Maybe (Direction, Int))
pathCommand parms = do
currentWalkabilityContext <- use walkabilityContext
-- First, check if the pathfinding target has a cached path.
eitherCachedPath <- retrieveCachedPath currentWalkabilityContext parms
case eitherCachedPath of
Right foundCachedPath -> return $ Just $ mkResult foundCachedPath
Left _ -> do
-- This is a short-circuiting optimization; if the goal location itself
-- is not a walkable cell, then no amount of searching will reach it.
isGoalLocWalkable <- case target of
LocationTarget loc -> null <$> checkMoveFailure (Cosmic currentSubworld loc)
EntityTarget _ -> return True
runMaybeT $ do
guard isGoalLocWalkable
maybeFoundPath <- lift computePath
foundPath <- hoistMaybe maybeFoundPath
-- NOTE: This will not cache the fact that a path was not found.
lift $ recordCache (fmap (^. subworld) parms) currentWalkabilityContext $ robotLoc :| foundPath
return $ mkResult foundPath
where
mkResult p = (nextDir p, length p)
PathfindingParameters maybeDistanceLimit (Cosmic currentSubworld robotLoc) target = parms
computePath =
aStarM
(neighborFunc withinDistanceLimit . Cosmic currentSubworld)
(const $ const $ return 1)
(return . distHeuristic)
goalReachedFunc
(return robotLoc)
withinDistanceLimit :: Location -> Bool
withinDistanceLimit = withinDistance distLimit robotLoc
directionTo :: Location -> Direction
directionTo nextLoc = DAbsolute $ nearestDirection $ nextLoc .-. robotLoc
-- Extracts the head of the found path to determine
-- the next direction for the robot to proceed along
nextDir :: [Location] -> Direction
nextDir pathLocs = case pathLocs of
[] -> DRelative DDown
(nextLoc : _) -> directionTo nextLoc
neighborFunc ::
HasRobotStepState sig m =>
(Location -> Bool) ->
Cosmic Location ->
m (HashSet Location)
neighborFunc isWithinRange loc = do
locs <- filterM isWalkableLoc neighborLocs
return $ HashSet.fromList $ map (view planar) locs
where
neighborLocs = getNeighborLocs loc
isWalkableLoc someLoc =
if not $ isWithinRange $ view planar someLoc
then return False
else do
isGoal <- goalReachedFunc $ view planar someLoc
if isGoal
then return True
else null <$> checkMoveFailureUnprivileged someLoc
-- This is an optimization for when a specific location
-- is given as the target.
-- However, it is not strictly necessary, and in fact
-- cannot be used when the target is a certain type of
-- entity.
distHeuristic :: Location -> Int32
distHeuristic = case target of
LocationTarget gLoc -> manhattan gLoc
EntityTarget _eName -> const 0
goalReachedFunc :: Has (State GameState) sig m => Location -> m Bool
goalReachedFunc loc = case target of
LocationTarget gLoc -> return $ loc == gLoc
EntityTarget eName -> do
me <- entityAt $ Cosmic currentSubworld loc
return $ (view entityName <$> me) == Just eName
-- A failsafe limit is hardcoded to prevent the game from freezing
-- if an error exists in some .sw code.
distLimit = maybe maxPathRange (min maxPathRange) maybeDistanceLimit