Save scenario completion status (#631)

- save the latest and best completion status for a scenario
  - scenarios are either `not started`/`in progress`/`completed` with the latter two also having time (real and in ticks)
- do not save completion status for `--cheat`ers
- show the completion status in the UI
- closes #357 

Co-authored-by: Brent Yorgey <byorgey@gmail.com>
This commit is contained in:
Ondřej Šebek 2022-08-30 08:53:43 +02:00 committed by GitHub
parent 9c816cd96a
commit 68333dd70a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 609 additions and 190 deletions

View File

@ -360,7 +360,7 @@ recipeLevels recipes start = levels
classicScenario :: ExceptT Text IO Scenario
classicScenario = do
entities <- loadEntities >>= guardRight "load entities"
loadScenario "data/scenarios/classic.yaml" entities
fst <$> loadScenario "data/scenarios/classic.yaml" entities
startingDevices :: Scenario -> Set Entity
startingDevices = Set.fromList . map snd . E.elems . view installedDevices . instantiateRobot 0 . head . view scenarioRobots

View File

@ -288,7 +288,7 @@ data EntityMap = EntityMap
{ entitiesByName :: Map Text Entity
, entitiesByCap :: Map Capability [Entity]
}
deriving (Generic, FromJSON, ToJSON)
deriving (Eq, Show, Generic, FromJSON, ToJSON)
instance Semigroup EntityMap where
EntityMap n1 c1 <> EntityMap n2 c2 = EntityMap (n1 <> n2) (c1 <> c2)

View File

@ -123,7 +123,7 @@ data RobotContext = RobotContext
-- definitions.
_defStore :: Store
}
deriving (Show, Generic, FromJSON, ToJSON)
deriving (Eq, Show, Generic, FromJSON, ToJSON)
makeLenses ''RobotContext
@ -199,6 +199,7 @@ data RobotR (phase :: RobotPhase) = RobotR
deriving (Generic)
deriving instance (Show (RobotLocation phase), Show (RobotID phase)) => Show (RobotR phase)
deriving instance (Eq (RobotLocation phase), Eq (RobotID phase)) => Eq (RobotR phase)
deriving instance (ToJSON (RobotLocation phase), ToJSON (RobotID phase)) => ToJSON (RobotR phase)

View File

@ -45,25 +45,18 @@ module Swarm.Game.Scenario (
-- * Loading from disk
loadScenario,
ScenarioCollection (..),
scenarioCollectionToList,
ScenarioItem (..),
_SISingle,
scenarioItemName,
loadScenarios,
loadScenarioFile,
) where
import Control.Algebra (Has)
import Control.Arrow ((&&&))
import Control.Carrier.Lift (Lift, sendIO)
import Control.Carrier.Throw.Either (Throw, runThrow, throwError)
import Control.Carrier.Throw.Either (Throw, throwError)
import Control.Lens hiding (from, (<.>))
import Control.Monad (filterM, unless, when)
import Control.Monad (filterM, when)
import Data.Aeson.Key qualified as Key
import Data.Aeson.KeyMap (KeyMap)
import Data.Aeson.KeyMap qualified as KeyMap
import Data.Char (isSpace)
import Data.List ((\\))
import Data.Map (Map)
import Data.Map qualified as M
import Data.Maybe (isNothing, listToMaybe)
@ -74,7 +67,7 @@ import Data.Yaml as Y
import GHC.Generics (Generic)
import GHC.Int (Int64)
import Linear.V2
import Paths_swarm (getDataDir, getDataFileName)
import Paths_swarm (getDataFileName)
import Swarm.Game.Entity
import Swarm.Game.Recipe
import Swarm.Game.Robot (TRobot, trobotName)
@ -82,24 +75,26 @@ import Swarm.Game.Terrain
import Swarm.Language.Pipeline (ProcessedTerm)
import Swarm.Util (reflow)
import Swarm.Util.Yaml
import System.Directory (doesDirectoryExist, doesFileExist, listDirectory)
import System.FilePath (takeBaseName, takeExtensions, (<.>), (</>))
import System.Directory (doesFileExist)
import System.FilePath ((<.>), (</>))
import Witch (from, into)
------------------------------------------------------------
-- Scenario objectives
------------------------------------------------------------
-- | An objective is a condition to be achieved by a player in a
-- scenario.
data Objective = Objective
{ _objectiveGoal :: [Text]
, _objectiveCondition :: ProcessedTerm
}
deriving (Show, Generic, ToJSON)
deriving (Eq, Show, Generic, ToJSON)
makeLensesWith (lensRules & generateSignatures .~ False) ''Objective
-- | An explanation of the goal of the objective, shown to the
-- player during play.
-- | An explanation of the goal of the objective, shown to the player
-- during play. It is represented as a list of paragraphs.
objectiveGoal :: Lens' Objective [Text]
-- | A winning condition for the objective, expressed as a
@ -160,6 +155,7 @@ data Cell = Cell
, cellEntity :: Maybe Entity
, cellRobot :: Maybe TRobot
}
deriving (Eq, Show)
-- | Parse a tuple such as @[grass, rock, base]@ into a 'Cell'. The
-- entity and robot, if present, are immediately looked up and
@ -193,6 +189,7 @@ instance FromJSONE (EntityMap, RobotMap) Cell where
-- | A world palette maps characters to 'Cell' values.
newtype WorldPalette = WorldPalette
{unPalette :: KeyMap Cell}
deriving (Eq, Show)
instance FromJSONE (EntityMap, RobotMap) WorldPalette where
parseJSONE = withObjectE "palette" $ fmap WorldPalette . mapM parseJSONE
@ -205,6 +202,7 @@ data WorldDescription = WorldDescription
, ul :: V2 Int64
, area :: [[Cell]]
}
deriving (Eq, Show)
instance FromJSONE (EntityMap, RobotMap) WorldDescription where
parseJSONE = withObjectE "world description" $ \v -> do
@ -247,6 +245,7 @@ data Scenario = Scenario
, _scenarioSolution :: Maybe ProcessedTerm
, _scenarioStepsPerTick :: Maybe Int
}
deriving (Eq, Show)
makeLensesWith (lensRules & generateSignatures .~ False) ''Scenario
@ -340,7 +339,7 @@ loadScenario ::
(Has (Lift IO) sig m, Has (Throw Text) sig m) =>
String ->
EntityMap ->
m Scenario
m (Scenario, FilePath)
loadScenario scenario em = do
libScenario <- sendIO $ getDataFileName $ "scenarios" </> scenario
libScenarioExt <- sendIO $ getDataFileName $ "scenarios" </> scenario <.> "yaml"
@ -351,104 +350,9 @@ loadScenario scenario em = do
case mfileName of
Nothing -> throwError @Text $ "Scenario not found: " <> from @String scenario
Just fileName -> loadScenarioFile em fileName
Just fileName -> (,fileName) <$> loadScenarioFile em fileName
-- | A scenario item is either a specific scenario, or a collection of
-- scenarios (*e.g.* the scenarios contained in a subdirectory).
data ScenarioItem = SISingle Scenario | SICollection Text ScenarioCollection
-- | Retrieve the name of a scenario item.
scenarioItemName :: ScenarioItem -> Text
scenarioItemName (SISingle s) = 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
}
-- | 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
-- | Load all the scenarios from the scenarios data directory.
loadScenarios :: (Has (Lift IO) sig m) => EntityMap -> m (Either Text ScenarioCollection)
loadScenarios em = runThrow $ do
dataDir <- sendIO getDataDir
loadScenarioDir em (dataDir </> "scenarios")
orderFileName :: FilePath
orderFileName = "00-ORDER.txt"
-- | 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 (Lift IO) sig m, Has (Throw Text) sig m) =>
EntityMap ->
FilePath ->
m ScenarioCollection
loadScenarioDir em dir = do
let orderFile = dir </> orderFileName
dirName = takeBaseName dir
orderExists <- sendIO $ doesFileExist orderFile
morder <- case orderExists of
False -> do
when (dirName /= "Testing") $
sendIO . putStrLn $
"Warning: no " <> orderFileName <> " file found in " <> dirName
<> ", using alphabetical order"
return Nothing
True -> Just . filter (not . null) . lines <$> sendIO (readFile orderFile)
fs <- sendIO $ keepYamlOrDirectory <$> listDirectory dir
case morder of
Just order -> do
let missing = fs \\ order
dangling = order \\ fs
unless (null missing) $
sendIO . putStr . unlines $
( "Warning: while processing " <> (dirName </> orderFileName) <> ": files not listed in "
<> orderFileName
<> " will be ignored"
) :
map (" - " <>) missing
unless (null dangling) $
sendIO . putStr . unlines $
( "Warning: while processing " <> (dirName </> orderFileName)
<> ": nonexistent files will be ignored"
) :
map (" - " <>) dangling
Nothing -> pure ()
-- Only keep the files from 00-ORDER.txt that actually exist.
let morder' = filter (`elem` fs) <$> morder
SC morder' . M.fromList <$> mapM (\item -> (item,) <$> loadScenarioItem em (dir </> item)) fs
where
keepYamlOrDirectory = filter (\f -> takeExtensions f `elem` ["", ".yaml"])
-- | Load a scenario item (either a scenario, or a subdirectory
-- containing a collection of scenarios) from a particular path.
loadScenarioItem ::
(Has (Lift IO) sig m, Has (Throw Text) sig m) =>
EntityMap ->
FilePath ->
m ScenarioItem
loadScenarioItem em path = do
isDir <- sendIO $ doesDirectoryExist path
let collectionName = into @Text . dropWhile isSpace . takeBaseName $ path
case isDir of
True -> SICollection collectionName <$> loadScenarioDir em path
False -> SISingle <$> loadScenarioFile em path
-- | Load a scenario from a file. The @Maybe Seed@ argument is a
-- seed provided by the user (either on the command line, or
-- specified through the UI), if any.
-- | Load a scenario from a file.
loadScenarioFile ::
(Has (Lift IO) sig m, Has (Throw Text) sig m) =>
EntityMap ->
@ -459,9 +363,3 @@ loadScenarioFile em fileName = do
case res of
Left parseExn -> throwError @Text (from @String (prettyPrintParseException parseExn))
Right c -> return c
------------------------------------------------------------
-- Some lenses + prisms
------------------------------------------------------------
makePrisms ''ScenarioItem

View File

@ -0,0 +1,337 @@
{-# LANGUAGE TemplateHaskell #-}
{-# OPTIONS_GHC -Wno-orphans #-}
{-# OPTIONS_GHC -Wno-partial-fields #-}
-- -Wno-orphans is for the Eq/Ord Time instances
-- |
-- Module : Swarm.Game.ScenarioStatus
-- Copyright : Brent Yorgey
-- Maintainer : byorgey@gmail.com
--
-- 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,
_InProgress,
_Complete,
ScenarioInfo (..),
scenarioPath,
scenarioStatus,
scenarioBestTime,
scenarioBestTicks,
updateScenarioInfoOnQuit,
-- * Scenario collection
ScenarioCollection (..),
scenarioCollectionToList,
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 (Lift, sendIO)
import Control.Carrier.Throw.Either (Throw, runThrow, throwError)
import Control.Lens hiding (from, (<.>))
import Control.Monad (unless, when)
import Data.Aeson (
Options (..),
defaultOptions,
genericParseJSON,
genericToEncoding,
genericToJSON,
)
import Data.Char (isSpace, toLower)
import Data.Function (on)
import Data.List (intercalate, stripPrefix, (\\))
import Data.Map (Map)
import Data.Map qualified as M
import Data.Maybe (isJust)
import Data.Text (Text, pack)
import Data.Time (NominalDiffTime, ZonedTime, diffUTCTime, zonedTimeToUTC)
import Data.Yaml as Y
import GHC.Generics (Generic)
import Paths_swarm (getDataDir)
import Swarm.Game.Entity
import Swarm.Game.Scenario
import Swarm.Util (getSwarmSavePath)
import System.Directory (canonicalizePath, doesDirectoryExist, doesFileExist, listDirectory)
import System.FilePath (pathSeparator, splitDirectories, takeBaseName, takeExtensions, (-<.>), (</>))
import Witch (into)
-- Some orphan ZonedTime instances
instance Eq ZonedTime where
(==) = (==) `on` zonedTimeToUTC
instance Ord ZonedTime where
(<=) = (<=) `on` zonedTimeToUTC
-- | A @ScenarioStatus@ stores the status of a scenario along with
-- appropriate metadata: not started, in progress, or complete.
-- Note that "in progress" is currently a bit of a misnomer since
-- games cannot be saved; at the moment it really means more like
-- "you played this scenario before but didn't win".
data ScenarioStatus
= NotStarted
| InProgress
{ -- | Time when the scenario was started including time zone.
_scenarioStarted :: ZonedTime
, -- | Time elapsed until quitting the scenario.
_scenarioElapsed :: NominalDiffTime
, -- | Ticks elapsed until quitting the scenario.
_scenarioElapsedTicks :: Integer
}
| Complete
{ -- | Time when the scenario was started including time zone.
_scenarioStarted :: ZonedTime
, -- | Time elapsed until quitting the scenario.
_scenarioElapsed :: NominalDiffTime
, -- | Ticks elapsed until quitting the scenario.
_scenarioElapsedTicks :: Integer
}
deriving (Eq, Ord, Show, Read, Generic)
instance FromJSON ScenarioStatus where
parseJSON = genericParseJSON scenarioOptions
instance ToJSON ScenarioStatus where
toEncoding = genericToEncoding scenarioOptions
toJSON = genericToJSON scenarioOptions
-- | A @ScenarioInfo@ record stores metadata about a scenario: its
-- canonical path, most recent status, and best-ever status.
data ScenarioInfo = ScenarioInfo
{ _scenarioPath :: FilePath
, _scenarioStatus :: ScenarioStatus
, _scenarioBestTime :: ScenarioStatus
, _scenarioBestTicks :: ScenarioStatus
}
deriving (Eq, Ord, Show, Read, Generic)
instance FromJSON ScenarioInfo where
parseJSON = genericParseJSON scenarioOptions
instance ToJSON ScenarioInfo where
toEncoding = genericToEncoding scenarioOptions
toJSON = genericToJSON scenarioOptions
scenarioOptions :: Options
scenarioOptions =
defaultOptions
{ fieldLabelModifier = map toLower . drop (length "_scenario")
}
makeLensesWith (lensRules & generateSignatures .~ False) ''ScenarioInfo
-- | The path of the scenario, relative to @data/scenarios@.
scenarioPath :: Lens' ScenarioInfo FilePath
-- | The status of the scenario.
scenarioStatus :: Lens' ScenarioInfo ScenarioStatus
-- | The best status of the scenario, measured in real world time.
scenarioBestTime :: Lens' ScenarioInfo ScenarioStatus
-- | The best status of the scenario, measured in game ticks.
scenarioBestTicks :: Lens' ScenarioInfo ScenarioStatus
-- | Update the current @ScenarioInfo@ record when quitting a game.
--
-- Note that when comparing "best" times, shorter is not always better!
-- As long as the scenario is not completed (e.g. some do not have win condition)
-- we consider having fun _longer_ to be better.
updateScenarioInfoOnQuit :: ZonedTime -> Integer -> Bool -> ScenarioInfo -> ScenarioInfo
updateScenarioInfoOnQuit z ticks completed (ScenarioInfo p s bTime bTicks) = case s of
InProgress start _ _ ->
let el = (diffUTCTime `on` zonedTimeToUTC) z start
cur = (if completed then Complete else InProgress) start el ticks
best f b = case b of
Complete {} | not completed || f b <= f cur -> b -- keep faster completed
InProgress {} | not completed && f b > f cur -> b -- keep longer progress (fun!)
_ -> cur -- otherwise update with current
in ScenarioInfo p cur (best _scenarioElapsed bTime) (best _scenarioElapsedTicks bTicks)
_ -> error "Logical error: trying to quit scenario which is not in progress!"
-- ----------------------------------------------------------------------------
-- 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 Scenario ScenarioInfo | SICollection Text ScenarioCollection
deriving (Eq, 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 (Eq, 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 :: ScenarioCollection -> FilePath -> IO FilePath
normalizeScenarioPath col p =
let path = p -<.> "yaml"
in if isJust $ col ^? scenarioItemByPath path
then return path
else do
canonPath <- canonicalizePath path
d <- getDataDir >>= canonicalizePath
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
-- | Load all the scenarios from the scenarios data directory.
loadScenarios :: (Has (Lift IO) sig m) => EntityMap -> m (Either Text ScenarioCollection)
loadScenarios em = runThrow $ do
dataDir <- sendIO getDataDir
loadScenarioDir em (dataDir </> "scenarios")
-- | The name of the special file which indicates the order of
-- scenarios in a folder.
orderFileName :: FilePath
orderFileName = "00-ORDER.txt"
-- | 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 (Lift IO) sig m, Has (Throw Text) sig m) =>
EntityMap ->
FilePath ->
m ScenarioCollection
loadScenarioDir em dir = do
let orderFile = dir </> orderFileName
dirName = takeBaseName dir
orderExists <- sendIO $ doesFileExist orderFile
morder <- case orderExists of
False -> do
when (dirName /= "Testing") $
sendIO . putStrLn $
"Warning: no " <> orderFileName <> " file found in " <> dirName
<> ", using alphabetical order"
return Nothing
True -> Just . filter (not . null) . lines <$> sendIO (readFile orderFile)
fs <- sendIO $ keepYamlOrDirectory <$> listDirectory dir
case morder of
Just order -> do
let missing = fs \\ order
dangling = order \\ fs
unless (null missing) $
sendIO . putStr . unlines $
( "Warning: while processing " <> (dirName </> orderFileName) <> ": files not listed in "
<> orderFileName
<> " will be ignored"
) :
map (" - " <>) missing
unless (null dangling) $
sendIO . putStr . unlines $
( "Warning: while processing " <> (dirName </> orderFileName)
<> ": nonexistent files will be ignored"
) :
map (" - " <>) dangling
Nothing -> pure ()
-- Only keep the files from 00-ORDER.txt that actually exist.
let morder' = filter (`elem` fs) <$> morder
SC morder' . M.fromList <$> mapM (\item -> (item,) <$> loadScenarioItem em (dir </> item)) fs
where
keepYamlOrDirectory = filter (\f -> takeExtensions f `elem` ["", ".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 (Lift IO) sig m, Has (Throw Text) 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 NotStarted NotStarted
else
sendIO (decodeFileEither infoPath)
>>= either (throwError . pack . prettyPrintParseException) return
-- | 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 (Lift IO) sig m, Has (Throw Text) sig m) =>
EntityMap ->
FilePath ->
m ScenarioItem
loadScenarioItem em path = do
isDir <- sendIO $ doesDirectoryExist path
let collectionName = into @Text . dropWhile isSpace . takeBaseName $ path
case isDir of
True -> SICollection collectionName <$> loadScenarioDir em path
False -> do
s <- loadScenarioFile em path
si <- loadScenarioInfo path
return $ SISingle s si
------------------------------------------------------------
-- Some lenses + prisms
------------------------------------------------------------
makePrisms ''ScenarioItem
makePrisms ''ScenarioStatus

View File

@ -2,6 +2,7 @@
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE QuasiQuotes #-}
{-# LANGUAGE RecordWildCards #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE StandaloneDeriving #-}
{-# LANGUAGE TemplateHaskell #-}
{-# LANGUAGE ViewPatterns #-}
@ -53,6 +54,7 @@ module Swarm.Game.State (
recipesIn,
recipesReq,
scenarios,
currentScenarioPath,
knownEntities,
world,
viewCenterRule,
@ -126,6 +128,7 @@ import Data.Set qualified as S
import Data.Text (Text)
import Data.Text qualified as T (lines)
import Data.Text.IO qualified as T (readFile)
import Data.Time (getZonedTime)
import GHC.Generics (Generic)
import Linear
import Paths_swarm (getDataFileName)
@ -139,7 +142,7 @@ import Swarm.Game.Recipe (
reqRecipeMap,
)
import Swarm.Game.Robot
import Swarm.Game.Scenario
import Swarm.Game.ScenarioInfo
import Swarm.Game.Terrain (TerrainType (..))
import Swarm.Game.Value (Value)
import Swarm.Game.World (Coords (..), WorldFun (..), locToCoords, worldFunFromArray)
@ -274,6 +277,7 @@ data GameState = GameState
, _recipesIn :: IntMap [Recipe Entity]
, _recipesReq :: IntMap [Recipe Entity]
, _scenarios :: ScenarioCollection
, _currentScenarioPath :: Maybe FilePath
, _knownEntities :: [Text]
, _world :: W.World Int Entity
, _viewCenterRule :: ViewCenterRule
@ -408,6 +412,12 @@ recipesReq :: Lens' GameState (IntMap [Recipe Entity])
-- | The collection of scenarios that comes with the game.
scenarios :: Lens' GameState ScenarioCollection
-- | The filepath of the currently running scenario.
--
-- This is useful as an index to 'scenarios' collection,
-- see 'Swarm.Game.ScenarioInfo.scenarioItemByPath'.
currentScenarioPath :: Lens' GameState (Maybe FilePath)
-- | The names of entities that should be considered "known", that is,
-- robots know what they are without having to scan them.
knownEntities :: Lens' GameState [Text]
@ -687,6 +697,7 @@ initGameState = do
, _recipesIn = inRecipeMap recipes
, _recipesReq = reqRecipeMap recipes
, _scenarios = loadedScenarios
, _currentScenarioPath = Nothing
, _knownEntities = []
, _world = W.emptyWorld (fromEnum StoneT)
, _viewCenterRule = VCRobot 0
@ -818,7 +829,7 @@ buildWorld em (WorldDescription {..}) = (robots, first fromEnum . wf)
robots :: [TRobot]
robots =
area
& traversed <.> traversed %@~ (,) -- add (r,c) indices
& traversed Control.Lens.<.> traversed %@~ (,) -- add (r,c) indices
& concat
& mapMaybe
( \((fromIntegral -> r, fromIntegral -> c), Cell _ _ robot) ->
@ -831,8 +842,14 @@ buildWorld em (WorldDescription {..}) = (robots, first fromEnum . wf)
initGameStateForScenario :: String -> Maybe Seed -> Maybe String -> ExceptT Text IO GameState
initGameStateForScenario sceneName userSeed toRun = do
g <- initGameState
scene <- loadScenario sceneName (g ^. entityMap)
liftIO $ scenarioToGameState scene userSeed toRun g
(scene, path) <- loadScenario sceneName (g ^. entityMap)
gs <- liftIO $ scenarioToGameState scene userSeed toRun g
normalPath <- liftIO $ normalizeScenarioPath (gs ^. scenarios) path
t <- liftIO getZonedTime
return $
gs
& currentScenarioPath ?~ normalPath
& scenarios . scenarioItemByPath normalPath . _SISingle . _2 . scenarioStatus .~ InProgress t 0 0
-- | For convenience, the 'GameState' corresponding to the classic
-- game with seed 0.

View File

@ -38,6 +38,8 @@ module Swarm.TUI.Attr (
highlightAttr,
notifAttr,
infoAttr,
boldAttr,
cyanAttr,
yellowAttr,
blueAttr,
greenAttr,
@ -72,11 +74,13 @@ swarmAttrMap =
, (infoAttr, fg (V.rgbColor @Int 50 50 50))
, (buttonSelectedAttr, bg V.blue)
, (notifAttr, fg V.yellow `V.withStyle` V.bold)
, (boldAttr, V.defAttr `V.withStyle` V.bold)
, -- Basic colors
(redAttr, fg V.red)
, (greenAttr, fg V.green)
, (blueAttr, fg V.blue)
, (yellowAttr, fg V.yellow)
, (cyanAttr, fg V.cyan)
, -- Default attribute
(defAttr, V.defAttr)
]
@ -143,19 +147,22 @@ plantAttr = worldPrefix <> attrName "plant"
highlightAttr
, notifAttr
, infoAttr
, boldAttr
, defAttr ::
AttrName
highlightAttr = attrName "highlight"
notifAttr = attrName "notif"
infoAttr = attrName "info"
boldAttr = attrName "bold"
defAttr = attrName "def"
-- | Some basic colors used in TUI.
redAttr, greenAttr, blueAttr, yellowAttr :: AttrName
redAttr, greenAttr, blueAttr, yellowAttr, cyanAttr :: AttrName
redAttr = attrName "red"
greenAttr = attrName "green"
blueAttr = attrName "blue"
yellowAttr = attrName "yellow"
cyanAttr = attrName "cyan"
instance ToJSON AttrName where
toJSON = toJSON . head . attrNameComponents

View File

@ -59,13 +59,13 @@ import Data.Map qualified as M
import Data.Maybe (fromMaybe, isJust, mapMaybe)
import Data.Text qualified as T
import Data.Text.IO qualified as T
import Data.Vector qualified as V
import Data.Time (getZonedTime)
import Graphics.Vty qualified as V
import Linear
import Swarm.Game.CESK (cancel, emptyStore, initMachine)
import Swarm.Game.Entity hiding (empty)
import Swarm.Game.Robot
import Swarm.Game.Scenario (Scenario, ScenarioCollection, ScenarioItem (..), objectiveGoal, scMap, scOrder, scenarioCollectionToList, scenarioItemName, _SISingle)
import Swarm.Game.ScenarioInfo
import Swarm.Game.State
import Swarm.Game.Step (gameTick)
import Swarm.Game.Value (Value (VUnit), prettyValue)
@ -144,10 +144,10 @@ handleMainMenuEvent menu = \case
-- Extract the first tutorial challenge and run it
let firstTutorial = case scOrder tutorialCollection of
Just (t : _) -> case M.lookup t (scMap tutorialCollection) of
Just (SISingle scene) -> scene
Just (SISingle scene si) -> (scene, si)
_ -> error "No first tutorial found!"
_ -> error "No first tutorial found!"
startGame firstTutorial
uncurry startGame firstTutorial Nothing
About -> uiState . uiMenu .= AboutMenu
Quit -> halt
CharKey 'q' -> halt
@ -162,22 +162,6 @@ getTutorials sc = case M.lookup "Tutorials" (scMap sc) of
Just (SICollection _ c) -> c
_ -> error "No tutorials exist!"
-- | Load a 'Scenario' and start playing the game.
startGame :: Scenario -> EventM Name AppState ()
startGame scene = do
menu <- use $ uiState . uiMenu
case menu of
NewGameMenu (curMenu :| _) ->
let nextMenuList = BL.listMoveDown curMenu
isLastScenario = BL.listSelected curMenu == Just (length (BL.listElements curMenu) - 1)
nextScenario =
if isLastScenario
then Nothing
else BL.listSelectedElement nextMenuList >>= preview _SISingle . snd
in uiState . uiNextScenario .= nextScenario
_ -> uiState . uiNextScenario .= Nothing
scenarioToAppState scene Nothing Nothing
-- | If we are in a New Game menu, advance the menu to the next item in order.
advanceMenu :: Menu -> Menu
advanceMenu = _NewGameMenu . lens NE.head (\(_ :| t) a -> a :| t) %~ BL.listMoveDown
@ -187,7 +171,7 @@ handleNewGameMenuEvent scenarioStack@(curMenu :| rest) = \case
Key V.KEnter ->
case snd <$> BL.listSelectedElement curMenu of
Nothing -> continueWithoutRedraw
Just (SISingle scene) -> startGame scene
Just (SISingle scene si) -> startGame scene si Nothing
Just (SICollection _ c) -> do
cheat <- use $ uiState . uiCheatMode
uiState . uiMenu .= NewGameMenu (NE.cons (mkScenarioList cheat c) scenarioStack)
@ -199,11 +183,6 @@ handleNewGameMenuEvent scenarioStack@(curMenu :| rest) = \case
uiState . uiMenu .= NewGameMenu (menu' :| rest)
_ -> continueWithoutRedraw
mkScenarioList :: Bool -> ScenarioCollection -> BL.List Name ScenarioItem
mkScenarioList cheat = flip (BL.list ScenarioList) 1 . V.fromList . filterTest . scenarioCollectionToList
where
filterTest = if cheat then id else filter (\case SICollection n _ -> n /= "Testing"; _ -> True)
exitNewGameMenu :: NonEmpty (BL.List Name ScenarioItem) -> EventM Name AppState ()
exitNewGameMenu stk = do
uiState . uiMenu
@ -367,7 +346,7 @@ handleModalEvent = \case
toggleModal QuitModal
case dialogSelection <$> mdialog of
Just (Just QuitButton) -> quitGame
Just (Just (NextButton scene)) -> startGame scene
Just (Just (NextButton scene)) -> saveScenarioInfoOnQuit >> uncurry startGame scene Nothing
_ -> return ()
ev -> do
Brick.zoom (uiState . uiModal . _Just . modalDialog) (handleDialogEvent ev)
@ -376,13 +355,45 @@ handleModalEvent = \case
Just _ -> handleInfoPanelEvent modalScroll (VtyEvent ev)
_ -> return ()
-- | Quit a game. Currently all it does is write out the updated REPL
-- history to a @.swarm_history@ file, and return to the previous menu.
-- | Write the @ScenarioInfo@ out to disk when exiting a game.
saveScenarioInfoOnQuit :: (MonadIO m, MonadState AppState m) => m ()
saveScenarioInfoOnQuit = do
-- Don't save progress if we are in cheat mode
cheat <- use $ uiState . uiCheatMode
unless cheat $ do
-- the path should be normalized and good to search in scenario collection
mp' <- use $ gameState . currentScenarioPath
case mp' of
Nothing -> return ()
Just p' -> do
gs <- use $ gameState . scenarios
p <- liftIO $ normalizeScenarioPath gs p'
t <- liftIO getZonedTime
won <- isJust <$> preuse (gameState . winCondition . _Won)
ts <- use $ gameState . ticks
let currentScenarioInfo :: Traversal' AppState ScenarioInfo
currentScenarioInfo = gameState . scenarios . scenarioItemByPath p . _SISingle . _2
currentScenarioInfo %= updateScenarioInfoOnQuit t ts won
status <- preuse currentScenarioInfo
case status of
Nothing -> return ()
Just si -> liftIO $ saveScenarioInfo p si
-- rebuild the NewGameMenu so it gets the updated ScenarioInfo
sc <- use $ gameState . scenarios
forM_ (mkNewGameMenu cheat sc p) (uiState . uiMenu .=)
-- | Quit a game.
--
-- * writes out the updated REPL history to a @.swarm_history@ file
-- * saves current scenario status (InProgress/Completed)
-- * returns to the previous menu
quitGame :: EventM Name AppState ()
quitGame = do
history <- use $ uiState . uiReplHistory
let hist = mapMaybe getREPLEntry $ getLatestREPLHistoryItems maxBound history
liftIO $ (`T.appendFile` T.unlines hist) =<< getSwarmHistoryPath True
saveScenarioInfoOnQuit
menu <- use $ uiState . uiMenu
case menu of
NoMenu -> halt

View File

@ -27,6 +27,8 @@ module Swarm.TUI.Model (
mainMenu,
Menu (..),
_NewGameMenu,
mkScenarioList,
mkNewGameMenu,
-- * UI state
@ -69,7 +71,6 @@ module Swarm.TUI.Model (
uiPort,
uiMenu,
uiPlaying,
uiNextScenario,
uiCheatMode,
uiFocusRing,
uiWorldCursor,
@ -120,12 +121,14 @@ module Swarm.TUI.Model (
-- ** Initialization
initAppState,
startGame,
scenarioToAppState,
Seed,
-- ** Utility
focusedItem,
focusedEntity,
nextScenario,
) where
import Brick
@ -141,22 +144,40 @@ import Data.Bits (FiniteBits (finiteBitSize))
import Data.Foldable (toList)
import Data.List (findIndex, sortOn)
import Data.List.NonEmpty (NonEmpty (..))
import Data.List.NonEmpty qualified as NE
import Data.Map (Map)
import Data.Map qualified as M
import Data.Maybe (fromMaybe, isJust)
import Data.Sequence (Seq)
import Data.Sequence qualified as Seq
import Data.Text (Text)
import Data.Text qualified as T
import Data.Time (getZonedTime)
import Data.Vector qualified as V
import Network.Wai.Handler.Warp (Port)
import Swarm.Game.Entity as E
import Swarm.Game.Robot
import Swarm.Game.Scenario (Scenario, ScenarioItem, loadScenario)
import Swarm.Game.Scenario (Scenario, loadScenario)
import Swarm.Game.ScenarioInfo (
ScenarioCollection,
ScenarioInfo (..),
ScenarioItem (..),
ScenarioStatus (..),
normalizeScenarioPath,
scMap,
scenarioCollectionToList,
scenarioItemByPath,
scenarioPath,
scenarioStatus,
_SISingle,
)
import Swarm.Game.State
import Swarm.Game.World qualified as W
import Swarm.Language.Types
import Swarm.Util
import System.Clock
import System.FilePath (dropTrailingPathSeparator, splitPath, takeFileName)
import Witch (into)
------------------------------------------------------------
-- Custom UI label types
@ -406,7 +427,7 @@ data ModalType
| GoalModal [Text]
deriving (Eq, Show)
data ButtonSelection = CancelButton | QuitButton | NextButton Scenario
data ButtonSelection = CancelButton | QuitButton | NextButton (Scenario, ScenarioInfo)
data Modal = Modal
{ _modalType :: ModalType
@ -429,6 +450,33 @@ mainMenu e = BL.list MenuList (V.fromList [minBound .. maxBound]) 1 & BL.listMov
makePrisms ''Menu
-- | Create a brick 'BL.List' of scenario items from a 'ScenarioCollection'.
mkScenarioList :: Bool -> ScenarioCollection -> BL.List Name ScenarioItem
mkScenarioList cheat = flip (BL.list ScenarioList) 1 . V.fromList . filterTest . scenarioCollectionToList
where
filterTest = if cheat then id else filter (\case SICollection n _ -> n /= "Testing"; _ -> True)
-- | Given a 'ScenarioCollection' and a 'FilePath' which is the canonical
-- path to some folder or scenario, construct a 'NewGameMenu' stack
-- focused on the given item, if possible.
mkNewGameMenu :: Bool -> ScenarioCollection -> FilePath -> Maybe Menu
mkNewGameMenu cheat sc path = NewGameMenu . NE.fromList <$> go (Just sc) (splitPath path) []
where
go :: Maybe ScenarioCollection -> [FilePath] -> [BL.List Name ScenarioItem] -> Maybe [BL.List Name ScenarioItem]
go _ [] stk = Just stk
go Nothing _ _ = Nothing
go (Just curSC) (thing : rest) stk = go nextSC rest (lst : stk)
where
hasName :: ScenarioItem -> Bool
hasName (SISingle _ (ScenarioInfo pth _ _ _)) = takeFileName pth == thing
hasName (SICollection nm _) = nm == into @Text (dropTrailingPathSeparator thing)
lst = BL.listFindBy hasName (mkScenarioList cheat curSC)
nextSC = case M.lookup (dropTrailingPathSeparator thing) (scMap curSC) of
Just (SICollection _ c) -> Just c
_ -> Nothing
------------------------------------------------------------
-- Inventory list entries
------------------------------------------------------------
@ -456,7 +504,6 @@ data UIState = UIState
{ _uiPort :: Maybe Port
, _uiMenu :: Menu
, _uiPlaying :: Bool
, _uiNextScenario :: Maybe Scenario
, _uiCheatMode :: Bool
, _uiFocusRing :: FocusRing Name
, _uiWorldCursor :: Maybe W.Coords
@ -515,9 +562,6 @@ uiMenu :: Lens' UIState Menu
-- display the current menu.
uiPlaying :: Lens' UIState Bool
-- | The next scenario after the current one, if any.
uiNextScenario :: Lens' UIState (Maybe Scenario)
-- | Cheat mode, i.e. are we allowed to turn creative mode on and off?
uiCheatMode :: Lens' UIState Bool
@ -713,7 +757,6 @@ initUIState showMainMenu cheatMode = liftIO $ do
{ _uiPort = Nothing
, _uiMenu = if showMainMenu then MainMenu (mainMenu NewGame) else NoMenu
, _uiPlaying = not showMainMenu
, _uiNextScenario = Nothing
, _uiCheatMode = cheatMode
, _uiFocusRing = initFocusRing
, _uiWorldCursor = Nothing
@ -815,8 +858,34 @@ initAppState userSeed scenarioName toRun cheatMode = do
case skipMenu of
False -> return $ AppState gs ui
True -> do
scenario <- loadScenario (fromMaybe "classic" scenarioName) (gs ^. entityMap)
liftIO $ execStateT (scenarioToAppState scenario userSeed toRun) (AppState gs ui)
(scenario, path) <- loadScenario (fromMaybe "classic" scenarioName) (gs ^. entityMap)
execStateT
(startGame scenario (ScenarioInfo path NotStarted NotStarted NotStarted) toRun)
(AppState gs ui)
-- | Load a 'Scenario' and start playing the game.
startGame :: (MonadIO m, MonadState AppState m) => Scenario -> ScenarioInfo -> Maybe FilePath -> m ()
startGame scene si toRun = do
t <- liftIO getZonedTime
ss <- use $ gameState . scenarios
p <- liftIO $ normalizeScenarioPath ss (si ^. scenarioPath)
gameState . currentScenarioPath .= Just p
gameState . scenarios . scenarioItemByPath p . _SISingle . _2 . scenarioStatus .= InProgress t 0 0
scenarioToAppState scene Nothing toRun
-- | Extract the scenario which would come next in the menu from the
-- currently selected scenario (if any). Can return @Nothing@ if
-- either we are not in the @NewGameMenu@, or the current scenario
-- is the last among its siblings.
nextScenario :: Menu -> Maybe (Scenario, ScenarioInfo)
nextScenario = \case
NewGameMenu (curMenu :| _) ->
let nextMenuList = BL.listMoveDown curMenu
isLastScenario = BL.listSelected curMenu == Just (length (BL.listElements curMenu) - 1)
in if isLastScenario
then Nothing
else BL.listSelectedElement nextMenuList >>= preview _SISingle . snd
_ -> Nothing
-- XXX do we need to keep an old entity map around???

View File

@ -47,10 +47,12 @@ import Brick.Widgets.Dialog
import Brick.Widgets.List qualified as BL
import Brick.Widgets.Table qualified as BT
import Control.Lens hiding (Const, from)
import Control.Monad (guard)
import Control.Monad.Reader (withReaderT)
import Data.Array (range)
import Data.Bits (shiftL, shiftR, (.&.))
import Data.Foldable qualified as F
import Data.Functor (($>))
import Data.IntMap qualified as IM
import Data.List (intersperse)
import Data.List qualified as L
@ -64,6 +66,7 @@ import Data.Sequence qualified as Seq
import Data.String (fromString)
import Data.Text (Text)
import Data.Text qualified as T
import Data.Time (NominalDiffTime, defaultTimeLocale, formatTime)
import Graphics.Vty qualified as V
import Linear
import Network.Wai.Handler.Warp (Port)
@ -72,7 +75,15 @@ import Swarm.Game.Display
import Swarm.Game.Entity as E
import Swarm.Game.Recipe
import Swarm.Game.Robot
import Swarm.Game.Scenario (ScenarioItem (..), scenarioDescription, scenarioItemName, scenarioName)
import Swarm.Game.Scenario (scenarioDescription, scenarioName, scenarioObjectives)
import Swarm.Game.ScenarioInfo (
ScenarioItem (..),
ScenarioStatus (..),
scenarioBestTicks,
scenarioBestTime,
scenarioItemName,
scenarioStatus,
)
import Swarm.Game.State
import Swarm.Game.Terrain (terrainMap)
import Swarm.Game.World qualified as W
@ -132,17 +143,43 @@ drawNewGameMenuUI (l :| ls) =
. centerLayer
$ hBox
[ vBox
[ withAttr robotAttr . txt $ breadcrumbs ls
[ withAttr boldAttr . txt $ breadcrumbs ls
, txt " "
, vLimit 20 . hLimit 35
. BL.renderList (const drawScenarioItem) True
. BL.renderList (const $ padRight Max . drawScenarioItem) True
$ l
]
, padLeft (Pad 5) (maybe (txt "") (drawDescription . snd) (BL.listSelectedElement l))
]
where
drawScenarioItem (SISingle s) = padRight Max . txt $ s ^. scenarioName
drawScenarioItem (SICollection nm _) = padRight Max (txt nm) <+> withAttr robotAttr (txt ">")
drawScenarioItem (SISingle s si) = padRight (Pad 1) (drawStatusInfo s si) <+> txt (s ^. scenarioName)
drawScenarioItem (SICollection nm _) = padRight (Pad 1) (withAttr boldAttr $ txt " > ") <+> txt nm
drawStatusInfo s si = case si ^. scenarioBestTime of
NotStarted -> txt ""
InProgress {} -> case s ^. scenarioObjectives of
[] -> withAttr cyanAttr $ txt ""
_ -> withAttr yellowAttr $ txt ""
Complete {} -> withAttr greenAttr $ txt ""
describeStatus = \case
NotStarted -> txt "none"
InProgress _s e _t ->
withAttr yellowAttr . vBox $
[ txt "in progress"
, txt $ "(played for " <> formatTimeDiff e <> ")"
]
Complete _s e t ->
withAttr greenAttr . vBox $
[ txt $ "completed in " <> formatTimeDiff e
, hBox
[ txt "("
, drawTime t True
, txt " ticks)"
]
]
formatTimeDiff :: NominalDiffTime -> Text
formatTimeDiff = T.pack . formatTime defaultTimeLocale "%hh %mm %ss"
breadcrumbs :: [BL.List Name ScenarioItem] -> Text
breadcrumbs =
@ -152,8 +189,24 @@ drawNewGameMenuUI (l :| ls) =
. mapMaybe (fmap (scenarioItemName . snd) . BL.listSelectedElement)
drawDescription :: ScenarioItem -> Widget Name
drawDescription (SISingle s) = txtWrap (nonBlank (s ^. scenarioDescription))
drawDescription (SICollection _ _) = txtWrap " "
drawDescription (SISingle s si) = do
let oneBest = si ^. scenarioBestTime == si ^. scenarioBestTicks
let bestRealTime = if oneBest then "best:" else "best real time:"
let noSame = if oneBest then const Nothing else Just
let lastText = let la = "last:" in padRight (Pad $ T.length bestRealTime - T.length la) (txt la)
vBox . catMaybes $
[ Just $ txtWrap (nonBlank (s ^. scenarioDescription))
, Just $
padTop (Pad 3) $
padRight (Pad 1) (txt bestRealTime) <+> describeStatus (si ^. scenarioBestTime)
, noSame $ -- hide best game time if it is same as best real time
padTop (Pad 1) $
padRight (Pad 1) (txt "best game time:") <+> describeStatus (si ^. scenarioBestTicks)
, Just $
padTop (Pad 1) $
padRight (Pad 1) lastText <+> describeStatus (si ^. scenarioStatus)
]
nonBlank "" = " "
nonBlank t = t
@ -239,15 +292,27 @@ drawWorldCursorInfo :: GameState -> W.Coords -> Widget Name
drawWorldCursorInfo g i@(W.Coords (y, x)) =
hBox [drawLoc g i, txt $ " at " <> from (show x) <> " " <> from (show (y * (-1)))]
-- | Format the clock display to be shown in the upper right of the
-- world panel.
drawClockDisplay :: GameState -> Widget n
drawClockDisplay gs = hBox . intersperse (txt " ") $ catMaybes [clockWidget, pauseWidget]
where
clockWidget = drawTime (gs ^. ticks) (gs ^. paused) gs
pauseWidget = if gs ^. paused then Just $ txt "(PAUSED)" else Nothing
clockWidget = maybeDrawTime (gs ^. ticks) (gs ^. paused) gs
pauseWidget = guard (gs ^. paused) $> txt "(PAUSED)"
drawTime :: Integer -> Bool -> GameState -> Maybe (Widget n)
drawTime t showTicks gs =
justClock . str . mconcat $
-- | Check whether the currently focused robot (if any) has a clock
-- device installed.
clockInstalled :: GameState -> Bool
clockInstalled gs = case focusedRobot gs of
Nothing -> False
Just r
| countByName "clock" (r ^. installedDevices) > 0 -> True
| otherwise -> False
-- | Format a ticks count as a hexadecimal clock.
drawTime :: Integer -> Bool -> Widget n
drawTime t showTicks =
str . mconcat $
[ printf "%x" (t `shiftR` 20)
, ":"
, printf "%02x" ((t `shiftR` 12) .&. ((1 `shiftL` 8) - 1))
@ -255,13 +320,14 @@ drawTime t showTicks gs =
, printf "%02x" ((t `shiftR` 4) .&. ((1 `shiftL` 8) - 1))
]
++ if showTicks then [".", printf "%x" (t .&. ((1 `shiftL` 4) - 1))] else []
where
justClock = if clockInstalled then Just else const Nothing
clockInstalled = case focusedRobot gs of
Nothing -> False
Just r
| countByName "clock" (r ^. installedDevices) > 0 -> True
| otherwise -> False
-- | Return a possible time display, if the currently focused robot
-- has a clock device installed. The first argument is the number
-- of ticks (e.g. 943 = 0x3af), and the second argument indicates
-- whether the time should be shown down to single-tick resolution
-- (e.g. 0:00:3a.f) or not (e.g. 0:00:3a).
maybeDrawTime :: Integer -> Bool -> GameState -> Maybe (Widget n)
maybeDrawTime t showTicks gs = guard (clockInstalled gs) $> drawTime t showTicks
-- | Render the type of the current REPL input to be shown to the user.
drawType :: Polytype -> Widget Name
@ -375,10 +441,12 @@ generateModal s mt = Modal mt (dialog (Just title) buttons (maxModalWindowWidth
in ( ""
, Just
( 0
, [(nextMsg, NextButton scene) | Just scene <- [s ^. uiState . uiNextScenario]]
++ [ (stopMsg, QuitButton)
, (continueMsg, CancelButton)
]
, [ (nextMsg, NextButton scene)
| Just scene <- [nextScenario (s ^. uiState . uiMenu)]
]
++ [ (stopMsg, QuitButton)
, (continueMsg, CancelButton)
]
)
, sum (map length [nextMsg, stopMsg, continueMsg]) + 32
)
@ -575,7 +643,7 @@ messagesWidget gs = widgetList
drawLogEntry' e =
withAttr (colorLogs e) $
hBox
[ fromMaybe (txt "") $ drawTime (e ^. leTime) True gs
[ fromMaybe (txt "") $ maybeDrawTime (e ^. leTime) True gs
, padLeft (Pad 2) . txt $ "[" <> e ^. leRobotName <> "]"
, padLeft (Pad 1) . txt2 $ e ^. leText
]

View File

@ -25,6 +25,7 @@ module Swarm.Util (
readFileMay,
readFileMayT,
getSwarmDataPath,
getSwarmSavePath,
getSwarmHistoryPath,
readAppData,
@ -217,6 +218,14 @@ getSwarmDataPath createDirs = do
when createDirs (createDirectoryIfMissing True swarmData)
pure swarmData
-- | Get path to swarm saves, optionally creating necessary
-- directories.
getSwarmSavePath :: Bool -> IO FilePath
getSwarmSavePath createDirs = do
swarmSave <- getXdgDirectory XdgData ("swarm" </> "saves")
when createDirs (createDirectoryIfMissing True swarmSave)
pure swarmSave
-- | Get path to swarm history, optionally creating necessary
-- directories. This could fail if user has bad permissions
-- on his own $HOME or $XDG_DATA_HOME which is unlikely.

View File

@ -96,6 +96,7 @@ library
Swarm.Language.Pipeline.QQ
Swarm.Game.CESK
Swarm.Game.Scenario
Swarm.Game.ScenarioInfo
Swarm.Game.Display
Swarm.Game.Entity
Swarm.Game.Exception
@ -154,6 +155,7 @@ library
syb >= 0.7 && < 0.8,
template-haskell >= 2.16 && < 2.19,
text >= 1.2.4 && < 2.1,
time >= 1.9 && < 1.14,
unification-fd >= 0.11 && < 0.12,
unordered-containers >= 0.2.14 && < 0.3,
vector >= 0.12 && < 0.13,