swarm-0.4: src/Swarm/Game/ScenarioInfo.hs
{-# LANGUAGE TemplateHaskell #-}
-- -Wno-orphans is for the Eq/Ord Time instances
-- |
-- SPDX-License-Identifier: BSD-3-Clause
--
-- Saving and loading info about scenarios (status, path, etc.) as
-- well as loading recursive scenario collections.
module Swarm.Game.ScenarioInfo (
-- * Scenario info
ScenarioStatus (..),
_NotStarted,
ScenarioInfo (..),
scenarioPath,
scenarioStatus,
CodeSizeDeterminators (CodeSizeDeterminators),
updateScenarioInfoOnFinish,
ScenarioInfoPair,
-- * Scenario collection
ScenarioCollection (..),
scenarioCollectionToList,
flatten,
scenarioItemByPath,
normalizeScenarioPath,
ScenarioItem (..),
scenarioItemName,
_SISingle,
-- * Loading and saving scenarios
loadScenarios,
loadScenarioInfo,
saveScenarioInfo,
-- * Re-exports
module Swarm.Game.Scenario,
) where
import Control.Algebra (Has)
import Control.Carrier.Lift (runM)
import Control.Carrier.Throw.Either (runThrow)
import Control.Effect.Accum (Accum, add)
import Control.Effect.Lift (Lift, sendIO)
import Control.Effect.Throw (Throw, liftEither)
import Control.Lens hiding (from, (<.>))
import Control.Monad (filterM, forM_, when, (<=<))
import Control.Monad.IO.Class (MonadIO (liftIO))
import Data.Char (isSpace)
import Data.Either (partitionEithers)
import Data.Either.Extra (fromRight')
import Data.List (intercalate, isPrefixOf, stripPrefix, (\\))
import Data.List.NonEmpty qualified as NE
import Data.Map (Map)
import Data.Map qualified as M
import Data.Maybe (isJust)
import Data.Sequence (Seq)
import Data.Sequence qualified as Seq
import Data.Text (Text)
import Data.Yaml as Y
import Swarm.Game.Entity
import Swarm.Game.Failure
import Swarm.Game.ResourceLoading (getDataDirSafe, getSwarmSavePath)
import Swarm.Game.Scenario
import Swarm.Game.Scenario.Scoring.CodeSize
import Swarm.Game.Scenario.Status
import Swarm.Game.World.Typecheck (WorldMap)
import Swarm.Util.Effect (warn, withThrow)
import System.Directory (canonicalizePath, doesDirectoryExist, doesFileExist, listDirectory)
import System.FilePath (pathSeparator, splitDirectories, takeBaseName, takeExtensions, (-<.>), (</>))
import Witch (into)
-- ----------------------------------------------------------------------------
-- Scenario Item
-- ----------------------------------------------------------------------------
-- | A scenario item is either a specific scenario, or a collection of
-- scenarios (*e.g.* the scenarios contained in a subdirectory).
data ScenarioItem = SISingle ScenarioInfoPair | SICollection Text ScenarioCollection
deriving (Show)
-- | Retrieve the name of a scenario item.
scenarioItemName :: ScenarioItem -> Text
scenarioItemName (SISingle (s, _ss)) = s ^. scenarioName
scenarioItemName (SICollection name _) = name
-- | A scenario collection is a tree of scenarios, keyed by name,
-- together with an optional order. Invariant: every item in the
-- scOrder exists as a key in the scMap.
data ScenarioCollection = SC
{ scOrder :: Maybe [FilePath]
, scMap :: Map FilePath ScenarioItem
}
deriving (Show)
-- | Access and modify ScenarioItems in collection based on their path.
scenarioItemByPath :: FilePath -> Traversal' ScenarioCollection ScenarioItem
scenarioItemByPath path = ixp ps
where
ps = splitDirectories path
ixp :: (Applicative f) => [String] -> (ScenarioItem -> f ScenarioItem) -> ScenarioCollection -> f ScenarioCollection
ixp [] _ col = pure col
ixp [s] f (SC n m) = SC n <$> ix s f m
ixp (d : xs) f (SC n m) = SC n <$> ix d inner m
where
inner si = case si of
SISingle {} -> pure si
SICollection n' col -> SICollection n' <$> ixp xs f col
-- | Canonicalize a scenario path, making it usable as a unique key.
normalizeScenarioPath ::
(MonadIO m) =>
ScenarioCollection ->
FilePath ->
m FilePath
normalizeScenarioPath col p =
let path = p -<.> "yaml"
in if isJust $ col ^? scenarioItemByPath path
then return path
else liftIO $ do
canonPath <- canonicalizePath path
eitherDdir <- runM . runThrow @SystemFailure $ getDataDirSafe Scenarios "." -- no way we got this far without data directory
d <- canonicalizePath $ fromRight' eitherDdir
let n =
stripPrefix (d </> "scenarios") canonPath
& maybe canonPath (dropWhile (== pathSeparator))
return n
-- | Convert a scenario collection to a list of scenario items.
scenarioCollectionToList :: ScenarioCollection -> [ScenarioItem]
scenarioCollectionToList (SC Nothing m) = M.elems m
scenarioCollectionToList (SC (Just order) m) = (m M.!) <$> order
flatten :: ScenarioItem -> [ScenarioInfoPair]
flatten (SISingle p) = [p]
flatten (SICollection _ c) = concatMap flatten $ scenarioCollectionToList c
-- | Load all the scenarios from the scenarios data directory.
loadScenarios ::
(Has (Accum (Seq SystemFailure)) sig m, Has (Lift IO) sig m) =>
EntityMap ->
WorldMap ->
m ScenarioCollection
loadScenarios em worldMap = do
res <- runThrow @SystemFailure $ getDataDirSafe Scenarios "scenarios"
case res of
Left err -> do
warn err
return $ SC mempty mempty
Right dataDir -> loadScenarioDir em worldMap dataDir
-- | The name of the special file which indicates the order of
-- scenarios in a folder.
orderFileName :: FilePath
orderFileName = "00-ORDER.txt"
readOrderFile :: (Has (Lift IO) sig m) => FilePath -> m [String]
readOrderFile orderFile =
filter (not . null) . lines <$> sendIO (readFile orderFile)
-- | Recursively load all scenarios from a particular directory, and also load
-- the 00-ORDER file (if any) giving the order for the scenarios.
loadScenarioDir ::
(Has (Accum (Seq SystemFailure)) sig m, Has (Lift IO) sig m) =>
EntityMap ->
WorldMap ->
FilePath ->
m ScenarioCollection
loadScenarioDir em worldMap dir = do
let orderFile = dir </> orderFileName
dirName = takeBaseName dir
orderExists <- sendIO $ doesFileExist orderFile
morder <- case orderExists of
False -> do
when (dirName /= "Testing") . warn $
OrderFileWarning (dirName </> orderFileName) NoOrderFile
return Nothing
True -> Just <$> readOrderFile orderFile
itemPaths <- sendIO $ keepYamlOrPublicDirectory dir =<< listDirectory dir
case morder of
Just order -> do
let missing = itemPaths \\ order
dangling = order \\ itemPaths
forM_ (NE.nonEmpty missing) $
warn
. OrderFileWarning (dirName </> orderFileName)
. MissingFiles
forM_ (NE.nonEmpty dangling) $
warn
. OrderFileWarning (dirName </> orderFileName)
. DanglingFiles
Nothing -> pure ()
-- Only keep the files from 00-ORDER.txt that actually exist.
let morder' = filter (`elem` itemPaths) <$> morder
loadItem filepath = do
item <- loadScenarioItem em worldMap (dir </> filepath)
return (filepath, item)
scenarios <- mapM (runThrow @SystemFailure . loadItem) itemPaths
let (failures, successes) = partitionEithers scenarios
scenarioMap = M.fromList successes
-- Now only keep the files that successfully parsed.
morder'' = filter (`M.member` scenarioMap) <$> morder'
collection = SC morder'' scenarioMap
add (Seq.fromList failures) -- Register failed individual scenarios as warnings
return collection
where
-- Keep only files which are .yaml files or directories that start
-- with something other than an underscore.
keepYamlOrPublicDirectory = filterM . isCatalogEntry
-- Whether the directory or file should be included in the scenario catalog.
isCatalogEntry d f = do
isDir <- doesDirectoryExist $ d </> f
return $
if isDir
then not $ "_" `isPrefixOf` f
else takeExtensions f == ".yaml"
-- | How to transform scenario path to save path.
scenarioPathToSavePath :: FilePath -> FilePath -> FilePath
scenarioPathToSavePath path swarmData = swarmData </> Data.List.intercalate "_" (splitDirectories path)
-- | Load saved info about played scenario from XDG data directory.
loadScenarioInfo ::
(Has (Throw SystemFailure) sig m, Has (Lift IO) sig m) =>
FilePath ->
m ScenarioInfo
loadScenarioInfo p = do
path <- sendIO $ normalizeScenarioPath (SC Nothing mempty) p
infoPath <- sendIO $ scenarioPathToSavePath path <$> getSwarmSavePath False
hasInfo <- sendIO $ doesFileExist infoPath
if not hasInfo
then do
return $
ScenarioInfo path NotStarted
else
withThrow (AssetNotLoaded (Data Scenarios) infoPath . CanNotParseYaml)
. (liftEither <=< sendIO)
$ decodeFileEither infoPath
-- | Save info about played scenario to XDG data directory.
saveScenarioInfo ::
FilePath ->
ScenarioInfo ->
IO ()
saveScenarioInfo path si = do
infoPath <- scenarioPathToSavePath path <$> getSwarmSavePath True
encodeFile infoPath si
-- | Load a scenario item (either a scenario, or a subdirectory
-- containing a collection of scenarios) from a particular path.
loadScenarioItem ::
( Has (Throw SystemFailure) sig m
, Has (Accum (Seq SystemFailure)) sig m
, Has (Lift IO) sig m
) =>
EntityMap ->
WorldMap ->
FilePath ->
m ScenarioItem
loadScenarioItem em worldMap path = do
isDir <- sendIO $ doesDirectoryExist path
let collectionName = into @Text . dropWhile isSpace . takeBaseName $ path
case isDir of
True -> SICollection collectionName <$> loadScenarioDir em worldMap path
False -> do
s <- loadScenarioFile em worldMap path
eitherSi <- runThrow @SystemFailure (loadScenarioInfo path)
case eitherSi of
Right si -> return $ SISingle (s, si)
Left warning -> do
warn warning
return $ SISingle (s, ScenarioInfo path NotStarted)
------------------------------------------------------------
-- Some lenses + prisms
------------------------------------------------------------
makePrisms ''ScenarioItem
makePrisms ''ScenarioStatus