diff --git a/.github/mergify.yml b/.github/mergify.yml index 107043832..e562c3446 100644 --- a/.github/mergify.yml +++ b/.github/mergify.yml @@ -1,6 +1,11 @@ queue_rules: - name: default - conditions: + merge_method: squash + commit_message_template: | + {{ title }} (#{{ number }}) + + {{ body }} + queue_conditions: - or: - and: - -files~=\.hs$ @@ -30,11 +35,6 @@ pull_request_rules: - actions: queue: name: default - method: squash - commit_message_template: | - {{ title }} (#{{ number }}) - - {{ body }} name: Automatically merge pull requests conditions: diff --git a/app/game/Main.hs b/app/game/Main.hs index d43a88a4e..cde368d9c 100644 --- a/app/game/Main.hs +++ b/app/game/Main.hs @@ -67,6 +67,7 @@ cliParser = scriptToRun <- run pausedAtStart <- paused autoPlay <- autoplay + autoShowObjectives <- not <$> hideGoal speed <- speedFactor debugOptions <- debug cheatMode <- cheat @@ -127,6 +128,8 @@ cliParser = paused = switch (long "paused" <> short 'p' <> help "Pause the game at start.") autoplay :: Parser Bool autoplay = switch (long "autoplay" <> short 'a' <> help "Automatically run the solution defined in the scenario, if there is one. Mutually exclusive with --run.") + hideGoal :: Parser Bool + hideGoal = switch (long "hide-goal" <> help "Do not show goal modal window that pauses the game.") speedFactor :: Parser Int speedFactor = option auto (long "speed" <> short 'm' <> metavar "N" <> value defaultInitLgTicksPerSecond <> help speedFactorHelp) speedFactorHelp = diff --git a/src/swarm-engine/Swarm/Game/State.hs b/src/swarm-engine/Swarm/Game/State.hs index 883ba8dd4..04d517e6e 100644 --- a/src/swarm-engine/Swarm/Game/State.hs +++ b/src/swarm-engine/Swarm/Game/State.hs @@ -461,7 +461,9 @@ initGameState :: GameStateConfig -> GameState initGameState gsc = GameState { _creativeMode = False - , _temporal = initTemporalState $ startPaused gsc + , _temporal = + initTemporalState (startPaused gsc) + & pauseOnObjective .~ (if pauseOnObjectiveCompletion gsc then PauseOnAnyObjective else PauseOnWin) , _winCondition = NoWinCondition , _winSolution = Nothing , _robotInfo = initRobots gsc diff --git a/src/swarm-engine/Swarm/Game/State/Runtime.hs b/src/swarm-engine/Swarm/Game/State/Runtime.hs index 411153d47..f48f53c84 100644 --- a/src/swarm-engine/Swarm/Game/State/Runtime.hs +++ b/src/swarm-engine/Swarm/Game/State/Runtime.hs @@ -79,17 +79,18 @@ initGameStateConfig :: , Has (Accum (Seq SystemFailure)) sig m , Has (Lift IO) sig m ) => - Bool -> + RuntimeOptions -> m GameStateConfig -initGameStateConfig pause = do - gsi <- initGameStateInputs - appDataMap <- readAppData - nameGen <- initNameGenerator appDataMap - return $ GameStateConfig appDataMap nameGen pause gsi +initGameStateConfig RuntimeOptions {..} = do + initAppDataMap <- readAppData + nameParts <- initNameGenerator initAppDataMap + initState <- initGameStateInputs + return $ GameStateConfig {..} -- | Runtime state initialization options. data RuntimeOptions = RuntimeOptions - { gamePausedAtStart :: Bool + { startPaused :: Bool + , pauseOnObjectiveCompletion :: Bool , loadTestScenarios :: Bool } deriving (Eq, Show) @@ -101,10 +102,9 @@ initRuntimeState :: ) => RuntimeOptions -> m RuntimeState -initRuntimeState RuntimeOptions {..} = do - gsc <- initGameStateConfig gamePausedAtStart - scenarios <- loadScenarios (gsiScenarioInputs $ initState gsc) loadTestScenarios - +initRuntimeState opts = do + gsc <- initGameStateConfig opts + scenarios <- loadScenarios (gsiScenarioInputs $ initState gsc) (loadTestScenarios opts) return $ RuntimeState { _webPort = Nothing diff --git a/src/swarm-engine/Swarm/Game/State/Substate.hs b/src/swarm-engine/Swarm/Game/State/Substate.hs index f271b33fe..85305ab0b 100644 --- a/src/swarm-engine/Swarm/Game/State/Substate.hs +++ b/src/swarm-engine/Swarm/Game/State/Substate.hs @@ -27,12 +27,14 @@ module Swarm.Game.State.Substate ( -- *** Temporal state TemporalState, + PauseOnObjective (..), initTemporalState, gameStep, runStatus, ticks, robotStepsPerTick, paused, + pauseOnObjective, -- *** Recipes Recipes, @@ -146,7 +148,7 @@ data WinStatus -- The boolean indicates whether they have -- already been informed. Unwinnable Bool - deriving (Show, Generic, FromJSON, ToJSON) + deriving (Eq, Show, Generic, FromJSON, ToJSON) data WinCondition = -- | There is no winning condition. @@ -274,11 +276,15 @@ data SingleStep -- | Game step mode - we use the single step mode when debugging robot 'CESK' machine. data Step = WorldTick | RobotStep SingleStep +data PauseOnObjective = PauseOnWin | PauseOnAnyObjective + deriving (Eq, Ord, Show, Enum, Bounded) + data TemporalState = TemporalState { _gameStep :: Step , _runStatus :: RunStatus , _ticks :: TickNumber , _robotStepsPerTick :: Int + , _pauseOnObjective :: PauseOnObjective } makeLensesNoSigs ''TemporalState @@ -300,6 +306,9 @@ ticks :: Lens' TemporalState TickNumber -- a single tick. robotStepsPerTick :: Lens' TemporalState Int +-- | Whether to pause the game after an objective is completed. +pauseOnObjective :: Lens' TemporalState PauseOnObjective + data GameControls = GameControls { _replStatus :: REPLStatus , _replNextValueIndex :: Integer @@ -406,6 +415,7 @@ initTemporalState pausedAtStart = , _runStatus = if pausedAtStart then ManualPause else Running , _ticks = TickNumber 0 , _robotStepsPerTick = defaultRobotStepsPerTick + , _pauseOnObjective = PauseOnAnyObjective } initGameControls :: GameControls diff --git a/src/swarm-engine/Swarm/Game/Step.hs b/src/swarm-engine/Swarm/Game/Step.hs index df7a217f5..5fd0eaf7b 100644 --- a/src/swarm-engine/Swarm/Game/Step.hs +++ b/src/swarm-engine/Swarm/Game/Step.hs @@ -28,6 +28,7 @@ import Control.Effect.Lens import Control.Effect.Lift import Control.Lens as Lens hiding (Const, distrib, from, parts, use, uses, view, (%=), (+=), (.=), (<+=), (<>=)) import Control.Monad (foldM, forM_, unless, when) +import Data.Foldable.Extra (notNull) import Data.Functor (void) import Data.IntMap qualified as IM import Data.IntSet qualified as IS @@ -338,7 +339,13 @@ hypotheticalWinCheck em g ws oc = do Unwinnable _ -> grantAchievement LoseScenario _ -> return () - messageInfo . announcementQueue %= (>< Seq.fromList (map ObjectiveCompleted $ completionAnnouncementQueue finalAccumulator)) + queue <- messageInfo . announcementQueue Swarm.Util.<%= (>< Seq.fromList (map ObjectiveCompleted $ completionAnnouncementQueue finalAccumulator)) + shouldPause <- use $ temporal . pauseOnObjective + + let gameFinished = newWinState /= Ongoing + let finishedObjectives = notNull queue + when (gameFinished || (finishedObjectives && shouldPause == PauseOnAnyObjective)) $ + temporal . runStatus .= AutoPause mapM_ handleException $ exceptions finalAccumulator where diff --git a/src/swarm-scenario/Swarm/Game/State/Config.hs b/src/swarm-scenario/Swarm/Game/State/Config.hs index 03978f64b..f2e71322c 100644 --- a/src/swarm-scenario/Swarm/Game/State/Config.hs +++ b/src/swarm-scenario/Swarm/Game/State/Config.hs @@ -18,5 +18,7 @@ data GameStateConfig = GameStateConfig -- ^ Lists of words/adjectives for use in building random robot names. , startPaused :: Bool -- ^ Start the game paused - useful for debugging or competitive play. + , pauseOnObjectiveCompletion :: Bool + -- ^ Pause the game when any objective is completed. , initState :: GameStateInputs } diff --git a/src/swarm-tournament/Swarm/Web/Tournament/Validate.hs b/src/swarm-tournament/Swarm/Web/Tournament/Validate.hs index e8913a4d9..36f8674c4 100644 --- a/src/swarm-tournament/Swarm/Web/Tournament/Validate.hs +++ b/src/swarm-tournament/Swarm/Web/Tournament/Validate.hs @@ -30,7 +30,7 @@ import Swarm.Game.Scenario.Scoring.CodeSize (codeMetricsFromSyntax) import Swarm.Game.Scenario.Status (emptyLaunchParams) import Swarm.Game.State import Swarm.Game.State.Initialize (scenarioToGameState) -import Swarm.Game.State.Runtime (initGameStateConfig, initScenarioInputs) +import Swarm.Game.State.Runtime (RuntimeOptions (..), initGameStateConfig, initScenarioInputs, pauseOnObjectiveCompletion) import Swarm.Game.State.Substate (initState, seed) import Swarm.Game.Step.Validate (playUntilWin) import Swarm.Language.Pipeline @@ -184,7 +184,12 @@ gamestateFromScenarioText content = do . ExceptT . runThrow . evalAccum (mempty :: Seq SystemFailure) - $ initGameStateConfig False + . initGameStateConfig + $ RuntimeOptions + { startPaused = False + , pauseOnObjectiveCompletion = False + , loadTestScenarios = False + } let scenarioInputs = gsiScenarioInputs $ initState gsc scenarioObject <- initScenarioObject scenarioInputs content diff --git a/src/swarm-tui/Swarm/TUI/Controller.hs b/src/swarm-tui/Swarm/TUI/Controller.hs index a8476ec71..4410422c8 100644 --- a/src/swarm-tui/Swarm/TUI/Controller.hs +++ b/src/swarm-tui/Swarm/TUI/Controller.hs @@ -96,12 +96,10 @@ import Swarm.TUI.Launch.Model import Swarm.TUI.Launch.Prep (prepareLaunchDialog) import Swarm.TUI.List import Swarm.TUI.Model -import Swarm.TUI.Model.Goal +import Swarm.TUI.Model.Dialog import Swarm.TUI.Model.Name -import Swarm.TUI.Model.Popup (progressPopups) import Swarm.TUI.Model.Repl import Swarm.TUI.Model.StateUpdate -import Swarm.TUI.Model.Structure import Swarm.TUI.Model.UI import Swarm.Util hiding (both, (<<.=)) @@ -294,14 +292,14 @@ handleMainEvent forceRedraw ev = do Web (RunWebCode e r) -> runBaseWebCode e r UpstreamVersion _ -> error "version event should be handled by top-level handler" VtyEvent (V.EvResize _ _) -> invalidateCache - EscapeKey | Just m <- s ^. uiState . uiGameplay . uiModal -> closeModal m + EscapeKey | Just m <- s ^. uiState . uiGameplay . uiDialogs . uiModal -> closeModal m -- Pass to key handler (allows users to configure bindings) -- See Note [how Swarm event handlers work] VtyEvent (V.EvKey k m) | isJust (B.lookupVtyEvent k m keyHandler) -> void $ B.handleKey keyHandler k m -- pass keys on to modal event handler if a modal is open VtyEvent vev - | isJust (s ^. uiState . uiGameplay . uiModal) -> handleModalEvent vev + | isJust (s ^. uiState . uiGameplay . uiDialogs . uiModal) -> handleModalEvent vev MouseDown (TerrainListItem pos) V.BLeft _ _ -> uiState . uiGameplay . uiWorldEditor . terrainList %= BL.listMoveTo pos MouseDown (EntityPaintListItem pos) V.BLeft _ _ -> @@ -367,7 +365,7 @@ handleMainEvent forceRedraw ev = do closeModal :: Modal -> EventM Name AppState () closeModal m = do safeAutoUnpause - uiState . uiGameplay . uiModal .= Nothing + uiState . uiGameplay . uiDialogs . uiModal .= Nothing -- message modal is not autopaused, so update notifications when leaving it when ((m ^. modalType) == MessagesModal) $ do t <- use $ gameState . temporal . ticks @@ -377,7 +375,7 @@ closeModal m = do handleModalEvent :: V.Event -> EventM Name AppState () handleModalEvent = \case V.EvKey V.KEnter [] -> do - mdialog <- preuse $ uiState . uiGameplay . uiModal . _Just . modalDialog + mdialog <- preuse $ uiState . uiGameplay . uiDialogs . uiModal . _Just . modalDialog toggleModal QuitModal case dialogSelection =<< mdialog of Just (Button QuitButton, _) -> quitGame @@ -391,33 +389,33 @@ handleModalEvent = \case startGame siPair Nothing _ -> return () ev -> do - Brick.zoom (uiState . uiGameplay . uiModal . _Just . modalDialog) (handleDialogEvent ev) - modal <- preuse $ uiState . uiGameplay . uiModal . _Just . modalType + Brick.zoom (uiState . uiGameplay . uiDialogs . uiModal . _Just . modalDialog) (handleDialogEvent ev) + modal <- preuse $ uiState . uiGameplay . uiDialogs . uiModal . _Just . modalType case modal of Just TerrainPaletteModal -> refreshList $ uiState . uiGameplay . uiWorldEditor . terrainList Just EntityPaletteModal -> do refreshList $ uiState . uiGameplay . uiWorldEditor . entityPaintList Just GoalModal -> case ev of - V.EvKey (V.KChar '\t') [] -> uiState . uiGameplay . uiGoal . focus %= focusNext + V.EvKey (V.KChar '\t') [] -> uiState . uiGameplay . uiDialogs . uiGoal . focus %= focusNext _ -> do - focused <- use $ uiState . uiGameplay . uiGoal . focus + focused <- use $ uiState . uiGameplay . uiDialogs . uiGoal . focus case focusGetCurrent focused of Just (GoalWidgets w) -> case w of ObjectivesList -> do - lw <- use $ uiState . uiGameplay . uiGoal . listWidget + lw <- use $ uiState . uiGameplay . uiDialogs . uiGoal . listWidget newList <- refreshGoalList lw - uiState . uiGameplay . uiGoal . listWidget .= newList + uiState . uiGameplay . uiDialogs . uiGoal . listWidget .= newList GoalSummary -> handleInfoPanelEvent modalScroll (VtyEvent ev) _ -> handleInfoPanelEvent modalScroll (VtyEvent ev) Just StructuresModal -> case ev of - V.EvKey (V.KChar '\t') [] -> uiState . uiGameplay . uiStructure . structurePanelFocus %= focusNext + V.EvKey (V.KChar '\t') [] -> uiState . uiGameplay . uiDialogs . uiStructure . structurePanelFocus %= focusNext _ -> do - focused <- use $ uiState . uiGameplay . uiStructure . structurePanelFocus + focused <- use $ uiState . uiGameplay . uiDialogs . uiStructure . structurePanelFocus case focusGetCurrent focused of Just (StructureWidgets w) -> case w of StructuresList -> - refreshList $ uiState . uiGameplay . uiStructure . structurePanelListWidget + refreshList $ uiState . uiGameplay . uiDialogs . uiStructure . structurePanelListWidget StructureSummary -> handleInfoPanelEvent modalScroll (VtyEvent ev) _ -> handleInfoPanelEvent modalScroll (VtyEvent ev) _ -> handleInfoPanelEvent modalScroll (VtyEvent ev) diff --git a/src/swarm-tui/Swarm/TUI/Controller/EventHandlers/Main.hs b/src/swarm-tui/Swarm/TUI/Controller/EventHandlers/Main.hs index b38006f2d..7bf59d637 100644 --- a/src/swarm-tui/Swarm/TUI/Controller/EventHandlers/Main.hs +++ b/src/swarm-tui/Swarm/TUI/Controller/EventHandlers/Main.hs @@ -24,8 +24,8 @@ import Swarm.TUI.Controller.Util import Swarm.TUI.Editor.Model (isWorldEditorEnabled, worldOverdraw) import Swarm.TUI.Model import Swarm.TUI.Model.DebugOption (DebugOption (ToggleCreative, ToggleWorldEditor)) +import Swarm.TUI.Model.Dialog.Goal import Swarm.TUI.Model.Event (MainEvent (..), SwarmEvent (..)) -import Swarm.TUI.Model.Goal import Swarm.TUI.Model.UI import System.Clock (Clock (..), TimeSpec (..), getTime) @@ -88,7 +88,7 @@ toggleMessagesModal = do viewGoal :: EventM Name AppState () viewGoal = do s <- get - if hasAnythingToShow $ s ^. uiState . uiGameplay . uiGoal . goalsContent + if hasAnythingToShow $ s ^. uiState . uiGameplay . uiDialogs . uiGoal . goalsContent then toggleModal GoalModal else continueWithoutRedraw @@ -144,7 +144,7 @@ toggleREPLVisibility = do isRunning :: EventM Name AppState Bool isRunning = do - mt <- preuse $ uiState . uiGameplay . uiModal . _Just . modalType + mt <- preuse $ uiState . uiGameplay . uiDialogs . uiModal . _Just . modalType return $ maybe True isRunningModal mt whenRunning :: EventM Name AppState () -> EventM Name AppState () diff --git a/src/swarm-tui/Swarm/TUI/Controller/EventHandlers/Robot.hs b/src/swarm-tui/Swarm/TUI/Controller/EventHandlers/Robot.hs index 542346f76..b2a0bcefb 100644 --- a/src/swarm-tui/Swarm/TUI/Controller/EventHandlers/Robot.hs +++ b/src/swarm-tui/Swarm/TUI/Controller/EventHandlers/Robot.hs @@ -69,7 +69,7 @@ showEntityDescription = gets focusedEntity >>= maybe continueWithoutRedraw descr descriptionModal e = do s <- get resetViewport modalScroll - uiState . uiGameplay . uiModal ?= generateModal s (DescriptionModal e) + uiState . uiGameplay . uiDialogs . uiModal ?= generateModal s (DescriptionModal e) -- | Attempt to make an entity selected from the inventory, if the -- base is not currently busy. diff --git a/src/swarm-tui/Swarm/TUI/Controller/UpdateUI.hs b/src/swarm-tui/Swarm/TUI/Controller/UpdateUI.hs index 5083e1d5b..a2e30d42b 100644 --- a/src/swarm-tui/Swarm/TUI/Controller/UpdateUI.hs +++ b/src/swarm-tui/Swarm/TUI/Controller/UpdateUI.hs @@ -37,9 +37,9 @@ import Swarm.TUI.Controller.SaveScenario (saveScenarioInfoOnFinishNocheat) import Swarm.TUI.Controller.Util import Swarm.TUI.Model import Swarm.TUI.Model.DebugOption (DebugOption (..)) -import Swarm.TUI.Model.Goal +import Swarm.TUI.Model.Dialog.Goal +import Swarm.TUI.Model.Dialog.Popup (Popup (..), addPopup) import Swarm.TUI.Model.Name -import Swarm.TUI.Model.Popup (Popup (..), addPopup) import Swarm.TUI.Model.Repl import Swarm.TUI.Model.UI import Swarm.TUI.View.Objective qualified as GR @@ -188,10 +188,9 @@ updateUI = do -- * shows the player more "optional" goals they can continue to pursue doGoalUpdates :: EventM Name AppState Bool doGoalUpdates = do - curGoal <- use (uiState . uiGameplay . uiGoal . goalsContent) + curGoal <- use (uiState . uiGameplay . uiDialogs . uiGoal . goalsContent) curWinCondition <- use (gameState . winCondition) - announcementsSeq <- use (gameState . messageInfo . announcementQueue) - let announcementsList = toList announcementsSeq + announcementsList <- use (gameState . messageInfo . announcementQueue . to toList) -- Decide whether we need to update the current goal text and pop -- up a modal dialog. @@ -218,7 +217,7 @@ doGoalUpdates = do return True WinConditions _ oc -> do showHiddenGoals <- use $ uiState . uiDebugOptions . Lens.contains ShowHiddenGoals - currentModal <- preuse $ uiState . uiGameplay . uiModal . _Just . modalType + currentModal <- preuse $ uiState . uiGameplay . uiDialogs . uiModal . _Just . modalType let newGoalTracking = GoalTracking announcementsList $ constructGoalMap showHiddenGoals oc -- The "uiGoal" field is initialized with empty members, so we know that -- this will be the first time showing it if it will be nonempty after previously @@ -232,16 +231,14 @@ doGoalUpdates = do when (goalWasUpdated && not isEnding) $ do -- The "uiGoal" field is necessary at least to "persist" the data that is needed -- if the player chooses to later "recall" the goals dialog with CTRL+g. - uiState . uiGameplay . uiGoal .= goalDisplay newGoalTracking + uiState . uiGameplay . uiDialogs . uiGoal .= goalDisplay newGoalTracking -- This clears the "flag" that indicate that the goals dialog needs to be -- automatically popped up. gameState . messageInfo . announcementQueue .= mempty - isAutoPlay <- use $ uiState . uiGameplay . uiIsAutoPlay - showGoalsAnyway <- use $ uiState . uiDebugOptions . Lens.contains ShowGoalDialogsInAutoPlay - unless (isAutoPlay && not showGoalsAnyway) $ - openModal GoalModal + showObjectives <- use $ uiState . uiGameplay . uiAutoShowObjectives + when showObjectives $ openModal GoalModal return goalWasUpdated where diff --git a/src/swarm-tui/Swarm/TUI/Controller/Util.hs b/src/swarm-tui/Swarm/TUI/Controller/Util.hs index 20713716e..dff5581f9 100644 --- a/src/swarm-tui/Swarm/TUI/Controller/Util.hs +++ b/src/swarm-tui/Swarm/TUI/Controller/Util.hs @@ -76,7 +76,7 @@ openModal mt = do resetViewport modalScroll newModal <- gets $ flip generateModal mt ensurePause - uiState . uiGameplay . uiModal ?= newModal + uiState . uiGameplay . uiDialogs . uiModal ?= newModal -- Beep case mt of ScenarioEndModal _ -> do @@ -121,10 +121,10 @@ safeAutoUnpause = do toggleModal :: ModalType -> EventM Name AppState () toggleModal mt = do - modal <- use $ uiState . uiGameplay . uiModal + modal <- use $ uiState . uiGameplay . uiDialogs . uiModal case modal of Nothing -> openModal mt - Just _ -> uiState . uiGameplay . uiModal .= Nothing >> safeAutoUnpause + Just _ -> uiState . uiGameplay . uiDialogs . uiModal .= Nothing >> safeAutoUnpause setFocus :: FocusablePanel -> EventM Name AppState () setFocus name = uiState . uiGameplay . uiFocusRing %= focusSetCurrent (FocusablePanel name) diff --git a/src/swarm-tui/Swarm/TUI/Model.hs b/src/swarm-tui/Swarm/TUI/Model.hs index 821932c67..ce15747a6 100644 --- a/src/swarm-tui/Swarm/TUI/Model.hs +++ b/src/swarm-tui/Swarm/TUI/Model.hs @@ -255,6 +255,8 @@ data AppOpts = AppOpts -- ^ Pause the game on start by default. , autoPlay :: Bool -- ^ Automatically run the solution defined in the scenario file + , autoShowObjectives :: Bool + -- ^ Show objectives dialogs when an objective is achieved/failed. , speed :: Int -- ^ Initial game speed (logarithm) , debugOptions :: Set DebugOption @@ -275,6 +277,7 @@ defaultAppOpts = , userScenario = Nothing , scriptToRun = Nothing , pausedAtStart = False + , autoShowObjectives = True , autoPlay = False , speed = defaultInitLgTicksPerSecond , debugOptions = mempty diff --git a/src/swarm-tui/Swarm/TUI/Model/Achievements.hs b/src/swarm-tui/Swarm/TUI/Model/Achievements.hs index 60b895946..c6d0b8015 100644 --- a/src/swarm-tui/Swarm/TUI/Model/Achievements.hs +++ b/src/swarm-tui/Swarm/TUI/Model/Achievements.hs @@ -19,7 +19,7 @@ import Swarm.Game.Achievement.Attainment import Swarm.Game.Achievement.Definitions import Swarm.Game.Achievement.Persistence import Swarm.TUI.Model -import Swarm.TUI.Model.Popup (Popup (AchievementPopup), addPopup) +import Swarm.TUI.Model.Dialog.Popup (Popup (AchievementPopup), addPopup) import Swarm.TUI.Model.UI attainAchievement :: (MonadIO m, MonadState AppState m) => CategorizedAchievement -> m () diff --git a/src/swarm-tui/Swarm/TUI/Model/DebugOption.hs b/src/swarm-tui/Swarm/TUI/Model/DebugOption.hs index a3faaa981..5a7e12d14 100644 --- a/src/swarm-tui/Swarm/TUI/Model/DebugOption.hs +++ b/src/swarm-tui/Swarm/TUI/Model/DebugOption.hs @@ -20,7 +20,6 @@ data DebugOption | ListAllRobots | ListRobotIDs | ShowHiddenGoals - | ShowGoalDialogsInAutoPlay | LoadTestingScenarios deriving (Eq, Ord, Show, Enum, Bounded) @@ -32,7 +31,6 @@ debugOptionName = \case ListAllRobots -> "all_robots" ListRobotIDs -> "robot_id" ShowHiddenGoals -> "hidden_goals" - ShowGoalDialogsInAutoPlay -> "autoplay_goals" LoadTestingScenarios -> "testing" debugOptionDescription :: DebugOption -> String @@ -43,7 +41,6 @@ debugOptionDescription = \case ListAllRobots -> "list all robots (including system robots) in the robot panel" ListRobotIDs -> "list robot IDs in the robot panel" ShowHiddenGoals -> "show hidden objectives in the goal dialog" - ShowGoalDialogsInAutoPlay -> "show goal dialogs when running in autoplay" LoadTestingScenarios -> "load Testing folder in scenarios menu" readDebugOption :: String -> Maybe DebugOption diff --git a/src/swarm-tui/Swarm/TUI/Model/Dialog.hs b/src/swarm-tui/Swarm/TUI/Model/Dialog.hs new file mode 100644 index 000000000..95baff1f9 --- /dev/null +++ b/src/swarm-tui/Swarm/TUI/Model/Dialog.hs @@ -0,0 +1,9 @@ +module Swarm.TUI.Model.Dialog ( + module Swarm.TUI.Model.Dialog.Goal, + module Swarm.TUI.Model.Dialog.Popup, + module Swarm.TUI.Model.Dialog.Structure, +) where + +import Swarm.TUI.Model.Dialog.Goal +import Swarm.TUI.Model.Dialog.Popup +import Swarm.TUI.Model.Dialog.Structure diff --git a/src/swarm-tui/Swarm/TUI/Model/Goal.hs b/src/swarm-tui/Swarm/TUI/Model/Dialog/Goal.hs similarity index 98% rename from src/swarm-tui/Swarm/TUI/Model/Goal.hs rename to src/swarm-tui/Swarm/TUI/Model/Dialog/Goal.hs index bce891ff5..c02ff33ea 100644 --- a/src/swarm-tui/Swarm/TUI/Model/Goal.hs +++ b/src/swarm-tui/Swarm/TUI/Model/Dialog/Goal.hs @@ -5,7 +5,7 @@ -- SPDX-License-Identifier: BSD-3-Clause -- -- A UI-centric model for Objective presentation. -module Swarm.TUI.Model.Goal where +module Swarm.TUI.Model.Dialog.Goal where import Brick.Focus import Brick.Widgets.List qualified as BL diff --git a/src/swarm-tui/Swarm/TUI/Model/Popup.hs b/src/swarm-tui/Swarm/TUI/Model/Dialog/Popup.hs similarity index 98% rename from src/swarm-tui/Swarm/TUI/Model/Popup.hs rename to src/swarm-tui/Swarm/TUI/Model/Dialog/Popup.hs index 4f0666ba4..15ee84d52 100644 --- a/src/swarm-tui/Swarm/TUI/Model/Popup.hs +++ b/src/swarm-tui/Swarm/TUI/Model/Dialog/Popup.hs @@ -1,7 +1,7 @@ {-# LANGUAGE PatternSynonyms #-} {-# LANGUAGE TemplateHaskell #-} -module Swarm.TUI.Model.Popup ( +module Swarm.TUI.Model.Dialog.Popup ( -- * Popup types Popup (..), diff --git a/src/swarm-tui/Swarm/TUI/Model/Structure.hs b/src/swarm-tui/Swarm/TUI/Model/Dialog/Structure.hs similarity index 95% rename from src/swarm-tui/Swarm/TUI/Model/Structure.hs rename to src/swarm-tui/Swarm/TUI/Model/Dialog/Structure.hs index 73a8c23d5..6635524ee 100644 --- a/src/swarm-tui/Swarm/TUI/Model/Structure.hs +++ b/src/swarm-tui/Swarm/TUI/Model/Dialog/Structure.hs @@ -5,7 +5,7 @@ -- SPDX-License-Identifier: BSD-3-Clause -- -- A UI-centric model for Structure presentation. -module Swarm.TUI.Model.Structure where +module Swarm.TUI.Model.Dialog.Structure where import Brick.Focus import Brick.Widgets.List qualified as BL diff --git a/src/swarm-tui/Swarm/TUI/Model/StateUpdate.hs b/src/swarm-tui/Swarm/TUI/Model/StateUpdate.hs index ab6c9f493..6b8361d73 100644 --- a/src/swarm-tui/Swarm/TUI/Model/StateUpdate.hs +++ b/src/swarm-tui/Swarm/TUI/Model/StateUpdate.hs @@ -82,11 +82,10 @@ import Swarm.TUI.Launch.Model (toSerializableParams) import Swarm.TUI.Model import Swarm.TUI.Model.Achievements import Swarm.TUI.Model.DebugOption (DebugOption (LoadTestingScenarios)) -import Swarm.TUI.Model.Goal (emptyGoalDisplay) +import Swarm.TUI.Model.Dialog import Swarm.TUI.Model.KeyBindings import Swarm.TUI.Model.Name import Swarm.TUI.Model.Repl -import Swarm.TUI.Model.Structure import Swarm.TUI.Model.UI import Swarm.TUI.View.Attribute.Attr (getWorldAttrName, swarmAttrMap) import Swarm.TUI.View.Attribute.CustomStyling (toAttrPair) @@ -127,8 +126,15 @@ initPersistentState :: m (RuntimeState, UIState, KeyEventHandlingState) initPersistentState opts@(AppOpts {..}) = do (warnings :: Seq SystemFailure, (initRS, initUI, initKs)) <- runAccum mempty $ do - rs <- initRuntimeState $ RuntimeOptions pausedAtStart (Set.member LoadTestingScenarios debugOptions) - ui <- initUIState speed (not (skipMenu opts)) debugOptions + rs <- + initRuntimeState + RuntimeOptions + { startPaused = pausedAtStart + , pauseOnObjectiveCompletion = autoShowObjectives + , loadTestScenarios = Set.member LoadTestingScenarios debugOptions + } + let showMainMenu = not (skipMenu opts) + ui <- initUIState UIInitOptions {..} ks <- initKeyHandlingState return (rs, ui, ks) let initRS' = addWarnings initRS (F.toList warnings) @@ -257,7 +263,7 @@ scenarioToUIState isAutoplaying siPair@(scenario, _) gs u = do fst siPair ^. scenarioLandscape . scenarioAttrs ) swarmAttrMap - & uiGameplay . uiGoal .~ emptyGoalDisplay + & uiGameplay . uiDialogs . uiGoal .~ emptyGoalDisplay & uiGameplay . uiIsAutoPlay .~ isAutoplaying & uiGameplay . uiFocusRing .~ initFocusRing & uiGameplay . uiInventory . uiInventorySearch .~ Nothing @@ -271,7 +277,7 @@ scenarioToUIState isAutoplaying siPair@(scenario, _) gs u = do & uiGameplay . uiTiming . lastFrameTime .~ curTime & uiGameplay . uiWorldEditor . EM.entityPaintList %~ BL.listReplace entityList Nothing & uiGameplay . uiWorldEditor . EM.editingBounds . EM.boundsRect %~ setNewBounds - & uiGameplay . uiStructure + & uiGameplay . uiDialogs . uiStructure .~ StructureDisplay (SR.makeListWidget . M.elems $ gs ^. discovery . structureRecognition . automatons . originalStructureDefinitions) (focusSetCurrent (StructureWidgets StructuresList) $ focusRing $ map StructureWidgets enumerate) diff --git a/src/swarm-tui/Swarm/TUI/Model/UI.hs b/src/swarm-tui/Swarm/TUI/Model/UI.hs index 26faf7cda..72ef5d12f 100644 --- a/src/swarm-tui/Swarm/TUI/Model/UI.hs +++ b/src/swarm-tui/Swarm/TUI/Model/UI.hs @@ -31,7 +31,9 @@ module Swarm.TUI.Model.UI ( uiModal, uiGoal, uiStructure, + uiDialogs, uiIsAutoPlay, + uiAutoShowObjectives, uiAchievements, lgTicksPerSecond, lastFrameTime, @@ -56,6 +58,7 @@ module Swarm.TUI.Model.UI ( initFocusRing, defaultInitLgTicksPerSecond, initUIState, + UIInitOptions (..), ) where import Brick (AttrMap) @@ -88,12 +91,10 @@ import Swarm.TUI.Inventory.Sorting import Swarm.TUI.Launch.Model import Swarm.TUI.Launch.Prep import Swarm.TUI.Model.DebugOption (DebugOption) -import Swarm.TUI.Model.Goal +import Swarm.TUI.Model.Dialog import Swarm.TUI.Model.Menu import Swarm.TUI.Model.Name -import Swarm.TUI.Model.Popup import Swarm.TUI.Model.Repl -import Swarm.TUI.Model.Structure import Swarm.TUI.View.Attribute.Attr (swarmAttrMap) import Swarm.Util import Swarm.Util.Lens (makeLensesExcluding, makeLensesNoSigs) @@ -194,6 +195,28 @@ uiShowZero :: Lens' UIInventory Bool -- | Whether the Inventory ui panel should update uiInventoryShouldUpdate :: Lens' UIInventory Bool +-- | State that backs various modal dialogs +data UIDialogs = UIDialogs + { _uiModal :: Maybe Modal + , _uiGoal :: GoalDisplay + , _uiStructure :: StructureDisplay + } + +-- * Lenses for UIDialogs + +makeLensesNoSigs ''UIDialogs + +-- | When this is 'Just', it represents a modal to be displayed on +-- top of the UI, e.g. for the Help screen. +uiModal :: Lens' UIDialogs (Maybe Modal) + +-- | Status of the scenario goal: whether there is one, and whether it +-- has been displayed to the user initially. +uiGoal :: Lens' UIDialogs GoalDisplay + +-- | Definition and status of a recognizable structure +uiStructure :: Lens' UIDialogs StructureDisplay + -- | The main record holding the gameplay UI state. For access to the fields, -- see the lenses below. data UIGameplay = UIGameplay @@ -203,10 +226,9 @@ data UIGameplay = UIGameplay , _uiREPL :: REPLState , _uiInventory :: UIInventory , _uiScrollToEnd :: Bool - , _uiModal :: Maybe Modal - , _uiGoal :: GoalDisplay - , _uiStructure :: StructureDisplay + , _uiDialogs :: UIDialogs , _uiIsAutoPlay :: Bool + , _uiAutoShowObjectives :: Bool , _uiShowREPL :: Bool , _uiShowDebug :: Bool , _uiHideRobotsUntil :: TimeSpec @@ -241,22 +263,15 @@ uiREPL :: Lens' UIGameplay REPLState -- (used when a new log message is appended). uiScrollToEnd :: Lens' UIGameplay Bool --- | When this is 'Just', it represents a modal to be displayed on --- top of the UI, e.g. for the Help screen. -uiModal :: Lens' UIGameplay (Maybe Modal) - --- | Status of the scenario goal: whether there is one, and whether it --- has been displayed to the user initially. -uiGoal :: Lens' UIGameplay GoalDisplay - --- | Definition and status of a recognizable structure -uiStructure :: Lens' UIGameplay StructureDisplay +-- | State that backs various modal dialogs +uiDialogs :: Lens' UIGameplay UIDialogs --- | When running with @--autoplay@, suppress the goal dialogs. --- --- For development, the @--cheat@ flag shows goals again. +-- | When running with @--autoplay@ the progress will not be saved. uiIsAutoPlay :: Lens' UIGameplay Bool +-- | Do not open objectives modals on objective completion. +uiAutoShowObjectives :: Lens' UIGameplay Bool + -- | A toggle to expand or collapse the REPL by pressing @Ctrl-k@ uiShowREPL :: Lens' UIGameplay Bool @@ -336,6 +351,14 @@ initFocusRing = focusRing $ map FocusablePanel enumerate defaultInitLgTicksPerSecond :: Int defaultInitLgTicksPerSecond = 4 -- 2^4 = 16 ticks / second +data UIInitOptions = UIInitOptions + { speed :: Int + , showMainMenu :: Bool + , autoShowObjectives :: Bool + , debugOptions :: Set DebugOption + } + deriving (Eq, Show) + -- | Initialize the UI state. This needs to be in the IO monad since -- it involves reading a REPL history file, getting the current -- time, and loading text files from the data directory. The @Bool@ @@ -345,11 +368,9 @@ initUIState :: ( Has (Accum (Seq SystemFailure)) sig m , Has (Lift IO) sig m ) => - Int -> - Bool -> - Set DebugOption -> + UIInitOptions -> m UIState -initUIState speedFactor showMainMenu debug = do +initUIState UIInitOptions {..} = do historyT <- sendIO $ readFileMayT =<< getSwarmHistoryPath False let history = maybe [] (map mkREPLSubmission . T.lines) historyT startTime <- sendIO $ getTime Monotonic @@ -359,7 +380,7 @@ initUIState speedFactor showMainMenu debug = do UIState { _uiMenu = if showMainMenu then MainMenu (mainMenu NewGame) else NoMenu , _uiPlaying = not showMainMenu - , _uiDebugOptions = debug + , _uiDebugOptions = debugOptions , _uiLaunchConfig = launchConfigPanel , _uiAchievements = M.fromList $ map (view achievement &&& id) achievements , _uiAttrMap = swarmAttrMap @@ -379,16 +400,20 @@ initUIState speedFactor showMainMenu debug = do , _uiInventoryShouldUpdate = False } , _uiScrollToEnd = False - , _uiModal = Nothing - , _uiGoal = emptyGoalDisplay - , _uiStructure = emptyStructureDisplay + , _uiDialogs = + UIDialogs + { _uiModal = Nothing + , _uiGoal = emptyGoalDisplay + , _uiStructure = emptyStructureDisplay + } , _uiIsAutoPlay = False + , _uiAutoShowObjectives = autoShowObjectives , _uiTiming = UITiming { _uiShowFPS = False , _uiTPF = 0 , _uiFPS = 0 - , _lgTicksPerSecond = speedFactor + , _lgTicksPerSecond = speed , _lastFrameTime = startTime , _accumulatedTime = 0 , _lastInfoTime = 0 diff --git a/src/swarm-tui/Swarm/TUI/View.hs b/src/swarm-tui/Swarm/TUI/View.hs index 5242e34a7..b0d6286c8 100644 --- a/src/swarm-tui/Swarm/TUI/View.hs +++ b/src/swarm-tui/Swarm/TUI/View.hs @@ -54,7 +54,6 @@ import Data.Bits (shiftL, shiftR, (.&.)) import Data.Foldable (toList) 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 import Data.List.Extra (enumerate) @@ -69,11 +68,8 @@ import Data.Set qualified as Set (toList) import Data.Text (Text) import Data.Text qualified as T import Data.Time (NominalDiffTime, defaultTimeLocale, formatTime) -import Linear import Network.Wai.Handler.Warp (Port) -import Numeric (showFFloat) import Swarm.Constant -import Swarm.Game.CESK (CESK (..)) import Swarm.Game.Device (commandCost, commandsForDeviceCaps, enabledCommands, getMap, ingredients) import Swarm.Game.Display import Swarm.Game.Entity as E @@ -82,7 +78,6 @@ import Swarm.Game.Land import Swarm.Game.Location import Swarm.Game.Recipe import Swarm.Game.Robot -import Swarm.Game.Robot.Activity import Swarm.Game.Robot.Concrete import Swarm.Game.Scenario ( scenarioAuthor, @@ -114,7 +109,7 @@ import Swarm.Game.State.Landscape import Swarm.Game.State.Robot import Swarm.Game.State.Runtime import Swarm.Game.State.Substate -import Swarm.Game.Tick (TickNumber (..), addTicks) +import Swarm.Game.Tick (TickNumber (..)) import Swarm.Game.Universe import Swarm.Game.World.Coords import Swarm.Game.World.Gen (Seed) @@ -134,8 +129,8 @@ import Swarm.TUI.Launch.Model import Swarm.TUI.Launch.View import Swarm.TUI.Model import Swarm.TUI.Model.DebugOption (DebugOption (..)) +import Swarm.TUI.Model.Dialog.Goal (goalsContent, hasAnythingToShow) import Swarm.TUI.Model.Event qualified as SE -import Swarm.TUI.Model.Goal (goalsContent, hasAnythingToShow) import Swarm.TUI.Model.KeyBindings (handlerNameKeysDescription) import Swarm.TUI.Model.Repl import Swarm.TUI.Model.UI @@ -146,12 +141,10 @@ import Swarm.TUI.View.CellDisplay import Swarm.TUI.View.Logo import Swarm.TUI.View.Objective qualified as GR import Swarm.TUI.View.Popup +import Swarm.TUI.View.Robot import Swarm.TUI.View.Structure qualified as SR import Swarm.TUI.View.Util as VU import Swarm.Util -import Swarm.Util.UnitInterval -import Swarm.Util.WindowedCounter qualified as WC -import System.Clock (TimeSpec (..)) import Text.Printf import Text.Wrap import Witch (into) @@ -507,14 +500,6 @@ drawGameUI s = ) ] -renderCoordsString :: Cosmic Location -> String -renderCoordsString (Cosmic sw coords) = - unwords $ VU.locationToString coords : suffix - where - suffix = case sw of - DefaultRootSubworld -> [] - SubworldName swName -> ["in", T.unpack swName] - drawWorldCursorInfo :: WorldOverdraw -> GameState -> Cosmic Coords -> Widget Name drawWorldCursorInfo worldEditor g cCoords = case getStatic g coords of @@ -613,13 +598,13 @@ replHeight = 10 -- | Hide the cursor when a modal is set chooseCursor :: AppState -> [CursorLocation n] -> Maybe (CursorLocation n) -chooseCursor s locs = case s ^. uiState . uiGameplay . uiModal of +chooseCursor s locs = case s ^. uiState . uiGameplay . uiDialogs . uiModal of Nothing -> showFirstCursor s locs Just _ -> Nothing -- | Draw a dialog window, if one should be displayed right now. drawDialog :: AppState -> Widget Name -drawDialog s = case s ^. uiState . uiGameplay . uiModal of +drawDialog s = case s ^. uiState . uiGameplay . uiDialogs . uiModal of Just (Modal mt d) -> renderDialog d $ case mt of GoalModal -> drawModal s mt _ -> maybeScroll ModalViewport $ drawModal s mt @@ -633,7 +618,7 @@ drawModal s = \case RecipesModal -> availableListWidget (s ^. gameState) RecipeList CommandsModal -> commandsListWidget (s ^. gameState) MessagesModal -> availableListWidget (s ^. gameState) MessageList - StructuresModal -> SR.renderStructuresDisplay (s ^. gameState) (s ^. uiState . uiGameplay . uiStructure) + StructuresModal -> SR.renderStructuresDisplay (s ^. gameState) (s ^. uiState . uiGameplay . uiDialogs . uiStructure) ScenarioEndModal outcome -> padBottom (Pad 1) $ vBox $ @@ -650,7 +635,7 @@ drawModal s = \case DescriptionModal e -> descriptionWidget s e QuitModal -> padBottom (Pad 1) $ hCenter $ txt (quitMsg (s ^. uiState . uiMenu)) GoalModal -> - GR.renderGoalsDisplay (s ^. uiState . uiGameplay . uiGoal) $ + GR.renderGoalsDisplay (s ^. uiState . uiGameplay . uiDialogs . uiGoal) $ view (scenarioOperation . scenarioDescription) . fst <$> s ^. uiState . uiGameplay . scenarioRef KeepPlayingModal -> padLeftRight 1 $ @@ -660,133 +645,6 @@ drawModal s = \case TerrainPaletteModal -> EV.drawTerrainSelector s EntityPaletteModal -> EV.drawEntityPaintSelector s --- | Render the percentage of ticks that this robot was active. --- This indicator can take some time to "warm up" and stabilize --- due to the sliding window. --- --- == Use of previous tick --- The 'Swarm.Game.Step.gameTick' function runs all robots, then increments the current tick. --- So at the time we are rendering a frame, the current tick will always be --- strictly greater than any ticks stored in the 'WC.WindowedCounter' for any robot; --- hence 'WC.getOccupancy' will never be @1@ if we use the current tick directly as --- obtained from the 'ticks' function. --- So we "rewind" it to the previous tick for the purpose of this display. -renderDutyCycle :: GameState -> Robot -> Widget Name -renderDutyCycle gs robot = - withAttr dutyCycleAttr . str . flip (showFFloat (Just 1)) "%" $ dutyCyclePercentage - where - curTicks = gs ^. temporal . ticks - window = robot ^. activityCounts . activityWindow - - -- Rewind to previous tick - latestRobotTick = addTicks (-1) curTicks - dutyCycleRatio = WC.getOccupancy latestRobotTick window - - dutyCycleAttr = safeIndex dutyCycleRatio meterAttributeNames - - dutyCyclePercentage :: Double - dutyCyclePercentage = 100 * getValue dutyCycleRatio - -robotsListWidget :: AppState -> Widget Name -robotsListWidget s = hCenter table - where - table = - BT.renderTable - . BT.columnBorders False - . BT.setDefaultColAlignment BT.AlignCenter - -- Inventory count is right aligned - . BT.alignRight 4 - . BT.table - $ map (padLeftRight 1) <$> (headers : robotsTable) - headings = - [ "Name" - , "Age" - , "Pos" - , "Items" - , "Status" - , "Actns" - , "Cmds" - , "Cycles" - , "Activity" - , "Log" - ] - headers = withAttr robotAttr . txt <$> applyWhen debugRID ("ID" :) headings - robotsTable = mkRobotRow <$> robots - mkRobotRow robot = - applyWhen debugRID (idWidget :) cells - where - cells = - [ nameWidget - , str ageStr - , locWidget - , padRight (Pad 1) (str $ show rInvCount) - , statusWidget - , str $ show $ robot ^. activityCounts . tangibleCommandCount - , -- TODO(#1341): May want to expose the details of this histogram in - -- a per-robot pop-up - str . show . sum . M.elems $ robot ^. activityCounts . commandsHistogram - , str $ show $ robot ^. activityCounts . lifetimeStepCount - , renderDutyCycle (s ^. gameState) robot - , txt rLog - ] - - idWidget = str $ show $ robot ^. robotID - nameWidget = - hBox - [ renderDisplay (robot ^. robotDisplay) - , highlightSystem . txt $ " " <> robot ^. robotName - ] - - highlightSystem = if robot ^. systemRobot then withAttr highlightAttr else id - - ageStr - | age < 60 = show age <> "sec" - | age < 3600 = show (age `div` 60) <> "min" - | age < 3600 * 24 = show (age `div` 3600) <> "hour" - | otherwise = show (age `div` 3600 * 24) <> "day" - where - TimeSpec createdAtSec _ = robot ^. robotCreatedAt - TimeSpec nowSec _ = s ^. uiState . uiGameplay . uiTiming . lastFrameTime - age = nowSec - createdAtSec - - rInvCount = sum $ map fst . E.elems $ robot ^. robotEntity . entityInventory - rLog - | robot ^. robotLogUpdated = "x" - | otherwise = " " - - locWidget = hBox [worldCell, str $ " " <> locStr] - where - rCoords = fmap locToCoords rLoc - rLoc = robot ^. robotLocation - worldCell = - drawLoc - (s ^. uiState . uiGameplay) - g - rCoords - locStr = renderCoordsString rLoc - - statusWidget = case robot ^. machine of - Waiting {} -> txt "waiting" - _ - | isActive robot -> withAttr notifAttr $ txt "busy" - | otherwise -> withAttr greenAttr $ txt "idle" - - basePos :: Point V2 Double - basePos = realToFrac <$> fromMaybe origin (g ^? baseRobot . robotLocation . planar) - -- Keep the base and non system robot (e.g. no seed) - isRelevant robot = robot ^. robotID == 0 || not (robot ^. systemRobot) - -- Keep the robot that are less than 32 unit away from the base - isNear robot = creative || distance (realToFrac <$> robot ^. robotLocation . planar) basePos < 32 - robots :: [Robot] - robots = - filter (\robot -> debugAllRobots || (isRelevant robot && isNear robot)) - . IM.elems - $ g ^. robotInfo . robotMap - creative = g ^. creativeMode - debugRID = s ^. uiState . uiDebugOptions . Lens.contains ListRobotIDs - debugAllRobots = s ^. uiState . uiDebugOptions . Lens.contains ListAllRobots - g = s ^. gameState - helpWidget :: Seed -> Maybe Port -> KeyEventHandlingState -> Widget Name helpWidget theSeed mport keyState = padLeftRight 2 . vBox $ padTop (Pad 1) <$> [info, helpKeys, tips] @@ -1013,7 +871,7 @@ drawKeyMenu s = creative = s ^. gameState . creativeMode showCreative = s ^. uiState . uiDebugOptions . Lens.contains ToggleCreative showEditor = s ^. uiState . uiDebugOptions . Lens.contains ToggleWorldEditor - goal = hasAnythingToShow $ s ^. uiState . uiGameplay . uiGoal . goalsContent + goal = hasAnythingToShow $ s ^. uiState . uiGameplay . uiDialogs . uiGoal . goalsContent showZero = s ^. uiState . uiGameplay . uiInventory . uiShowZero inventorySort = s ^. uiState . uiGameplay . uiInventory . uiInventorySort inventorySearch = s ^. uiState . uiGameplay . uiInventory . uiInventorySearch diff --git a/src/swarm-tui/Swarm/TUI/View/Objective.hs b/src/swarm-tui/Swarm/TUI/View/Objective.hs index a1a8efb6d..8d0224c9a 100644 --- a/src/swarm-tui/Swarm/TUI/View/Objective.hs +++ b/src/swarm-tui/Swarm/TUI/View/Objective.hs @@ -20,7 +20,7 @@ import Swarm.Game.Scenario.Objective import Swarm.Language.Syntax (Syntax) import Swarm.Language.Text.Markdown (Document) import Swarm.Language.Text.Markdown qualified as Markdown -import Swarm.TUI.Model.Goal +import Swarm.TUI.Model.Dialog.Goal import Swarm.TUI.Model.Name import Swarm.TUI.View.Attribute.Attr import Swarm.TUI.View.Util diff --git a/src/swarm-tui/Swarm/TUI/View/Popup.hs b/src/swarm-tui/Swarm/TUI/View/Popup.hs index 28cb197a8..96b30e62f 100644 --- a/src/swarm-tui/Swarm/TUI/View/Popup.hs +++ b/src/swarm-tui/Swarm/TUI/View/Popup.hs @@ -15,8 +15,8 @@ import Swarm.Game.Achievement.Definitions (title) import Swarm.Game.Achievement.Description (describe) import Swarm.Language.Syntax (constInfo, syntax) import Swarm.TUI.Model (AppState, Name, uiState) +import Swarm.TUI.Model.Dialog.Popup (Popup (..), currentPopup, popupFrames) import Swarm.TUI.Model.Event qualified as SE -import Swarm.TUI.Model.Popup (Popup (..), currentPopup, popupFrames) import Swarm.TUI.Model.UI (uiPopups) import Swarm.TUI.View.Attribute.Attr (notifAttr) import Swarm.TUI.View.Util (bindingText) diff --git a/src/swarm-tui/Swarm/TUI/View/Robot.hs b/src/swarm-tui/Swarm/TUI/View/Robot.hs new file mode 100644 index 000000000..93ff2415b --- /dev/null +++ b/src/swarm-tui/Swarm/TUI/View/Robot.hs @@ -0,0 +1,167 @@ +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE NoGeneralizedNewtypeDeriving #-} + +-- | +-- SPDX-License-Identifier: BSD-3-Clause +-- +-- A UI-centric model for presentation of Robot details. +module Swarm.TUI.View.Robot where + +import Brick hiding (Direction, Location) +import Brick.Widgets.Center (hCenter) +import Brick.Widgets.Table qualified as BT +import Control.Lens as Lens hiding (Const, from) +import Data.IntMap qualified as IM +import Data.Map qualified as M +import Data.Maybe (fromMaybe) +import Linear +import Numeric (showFFloat) +import Swarm.Game.CESK (CESK (..)) +import Swarm.Game.Entity as E +import Swarm.Game.Location +import Swarm.Game.Robot +import Swarm.Game.Robot.Activity +import Swarm.Game.Robot.Concrete +import Swarm.Game.State +import Swarm.Game.State.Robot +import Swarm.Game.State.Substate +import Swarm.Game.Tick (addTicks) +import Swarm.Game.Universe +import Swarm.Game.World.Coords +import Swarm.TUI.Model +import Swarm.TUI.Model.DebugOption (DebugOption (..)) +import Swarm.TUI.Model.UI +import Swarm.TUI.View.Attribute.Attr +import Swarm.TUI.View.CellDisplay +import Swarm.TUI.View.Util as VU +import Swarm.Util +import Swarm.Util.UnitInterval +import Swarm.Util.WindowedCounter qualified as WC +import System.Clock (TimeSpec (..)) + +-- | Render the percentage of ticks that this robot was active. +-- This indicator can take some time to "warm up" and stabilize +-- due to the sliding window. +-- +-- == Use of previous tick +-- The 'Swarm.Game.Step.gameTick' function runs all robots, then increments the current tick. +-- So at the time we are rendering a frame, the current tick will always be +-- strictly greater than any ticks stored in the 'WC.WindowedCounter' for any robot; +-- hence 'WC.getOccupancy' will never be @1@ if we use the current tick directly as +-- obtained from the 'ticks' function. +-- So we "rewind" it to the previous tick for the purpose of this display. +renderDutyCycle :: GameState -> Robot -> Widget Name +renderDutyCycle gs robot = + withAttr dutyCycleAttr . str . flip (showFFloat (Just 1)) "%" $ dutyCyclePercentage + where + curTicks = gs ^. temporal . ticks + window = robot ^. activityCounts . activityWindow + + -- Rewind to previous tick + latestRobotTick = addTicks (-1) curTicks + dutyCycleRatio = WC.getOccupancy latestRobotTick window + + dutyCycleAttr = safeIndex dutyCycleRatio meterAttributeNames + + dutyCyclePercentage :: Double + dutyCyclePercentage = 100 * getValue dutyCycleRatio + +robotsListWidget :: AppState -> Widget Name +robotsListWidget s = hCenter table + where + table = + BT.renderTable + . BT.columnBorders False + . BT.setDefaultColAlignment BT.AlignCenter + -- Inventory count is right aligned + . BT.alignRight 4 + . BT.table + $ map (padLeftRight 1) <$> (headers : robotsTable) + headings = + [ "Name" + , "Age" + , "Pos" + , "Items" + , "Status" + , "Actns" + , "Cmds" + , "Cycles" + , "Activity" + , "Log" + ] + headers = withAttr robotAttr . txt <$> applyWhen debugRID ("ID" :) headings + robotsTable = mkRobotRow <$> robots + mkRobotRow robot = + applyWhen debugRID (idWidget :) cells + where + cells = + [ nameWidget + , str ageStr + , locWidget + , padRight (Pad 1) (str $ show rInvCount) + , statusWidget + , str $ show $ robot ^. activityCounts . tangibleCommandCount + , -- TODO(#1341): May want to expose the details of this histogram in + -- a per-robot pop-up + str . show . sum . M.elems $ robot ^. activityCounts . commandsHistogram + , str $ show $ robot ^. activityCounts . lifetimeStepCount + , renderDutyCycle (s ^. gameState) robot + , txt rLog + ] + + idWidget = str $ show $ robot ^. robotID + nameWidget = + hBox + [ renderDisplay (robot ^. robotDisplay) + , highlightSystem . txt $ " " <> robot ^. robotName + ] + + highlightSystem = if robot ^. systemRobot then withAttr highlightAttr else id + + ageStr + | age < 60 = show age <> "sec" + | age < 3600 = show (age `div` 60) <> "min" + | age < 3600 * 24 = show (age `div` 3600) <> "hour" + | otherwise = show (age `div` 3600 * 24) <> "day" + where + TimeSpec createdAtSec _ = robot ^. robotCreatedAt + TimeSpec nowSec _ = s ^. uiState . uiGameplay . uiTiming . lastFrameTime + age = nowSec - createdAtSec + + rInvCount = sum $ map fst . E.elems $ robot ^. robotEntity . entityInventory + rLog + | robot ^. robotLogUpdated = "x" + | otherwise = " " + + locWidget = hBox [worldCell, str $ " " <> locStr] + where + rCoords = fmap locToCoords rLoc + rLoc = robot ^. robotLocation + worldCell = + drawLoc + (s ^. uiState . uiGameplay) + g + rCoords + locStr = renderCoordsString rLoc + + statusWidget = case robot ^. machine of + Waiting {} -> txt "waiting" + _ + | isActive robot -> withAttr notifAttr $ txt "busy" + | otherwise -> withAttr greenAttr $ txt "idle" + + basePos :: Point V2 Double + basePos = realToFrac <$> fromMaybe origin (g ^? baseRobot . robotLocation . planar) + -- Keep the base and non system robot (e.g. no seed) + isRelevant robot = robot ^. robotID == 0 || not (robot ^. systemRobot) + -- Keep the robot that are less than 32 unit away from the base + isNear robot = creative || distance (realToFrac <$> robot ^. robotLocation . planar) basePos < 32 + robots :: [Robot] + robots = + filter (\robot -> debugAllRobots || (isRelevant robot && isNear robot)) + . IM.elems + $ g ^. robotInfo . robotMap + creative = g ^. creativeMode + debugRID = s ^. uiState . uiDebugOptions . Lens.contains ListRobotIDs + debugAllRobots = s ^. uiState . uiDebugOptions . Lens.contains ListAllRobots + g = s ^. gameState diff --git a/src/swarm-tui/Swarm/TUI/View/Structure.hs b/src/swarm-tui/Swarm/TUI/View/Structure.hs index 24d458114..11a903ee3 100644 --- a/src/swarm-tui/Swarm/TUI/View/Structure.hs +++ b/src/swarm-tui/Swarm/TUI/View/Structure.hs @@ -32,8 +32,8 @@ import Swarm.Game.Scenario.Topography.Structure.Recognition.Type import Swarm.Game.State import Swarm.Game.State.Substate (structureRecognition) import Swarm.Language.Syntax.Direction (directionJsonModifier) +import Swarm.TUI.Model.Dialog.Structure import Swarm.TUI.Model.Name -import Swarm.TUI.Model.Structure import Swarm.TUI.View.Attribute.Attr import Swarm.TUI.View.CellDisplay import Swarm.TUI.View.Util diff --git a/src/swarm-tui/Swarm/TUI/View/Util.hs b/src/swarm-tui/Swarm/TUI/View/Util.hs index 69f0b2869..63c3fe39e 100644 --- a/src/swarm-tui/Swarm/TUI/View/Util.hs +++ b/src/swarm-tui/Swarm/TUI/View/Util.hs @@ -25,6 +25,7 @@ import Swarm.Game.State import Swarm.Game.State.Landscape import Swarm.Game.State.Substate import Swarm.Game.Terrain +import Swarm.Game.Universe import Swarm.Language.Pretty (prettyTextLine) import Swarm.Language.Syntax (Syntax) import Swarm.Language.Text.Markdown qualified as Markdown @@ -255,3 +256,11 @@ bindingText s e = maybe "" ppBindingShort b Binding V.KLeft m | null m -> "←" Binding V.KRight m | null m -> "→" bi -> ppBinding bi + +renderCoordsString :: Cosmic Location -> String +renderCoordsString (Cosmic sw coords) = + unwords $ locationToString coords : suffix + where + suffix = case sw of + DefaultRootSubworld -> [] + SubworldName swName -> ["in", T.unpack swName] diff --git a/src/swarm-web/Swarm/Web.hs b/src/swarm-web/Swarm/Web.hs index 4668876bf..9150939f5 100644 --- a/src/swarm-web/Swarm/Web.hs +++ b/src/swarm-web/Swarm/Web.hs @@ -78,7 +78,7 @@ import Swarm.Game.Step.Path.Type import Swarm.Language.Pipeline (processTermEither) import Swarm.Language.Pretty (prettyTextLine) import Swarm.TUI.Model hiding (SwarmKeyDispatchers (..)) -import Swarm.TUI.Model.Goal +import Swarm.TUI.Model.Dialog.Goal import Swarm.TUI.Model.Repl (REPLHistItem, replHistory, replSeq) import Swarm.TUI.Model.UI import Swarm.Util.RingBuffer @@ -200,7 +200,7 @@ goalsGraphHandler appStateRef = do uiGoalHandler :: IO AppState -> Handler GoalTracking uiGoalHandler appStateRef = do appState <- liftIO appStateRef - return $ appState ^. uiState . uiGameplay . uiGoal . goalsContent + return $ appState ^. uiState . uiGameplay . uiDialogs . uiGoal . goalsContent goalsHandler :: IO AppState -> Handler WinCondition goalsHandler appStateRef = do diff --git a/swarm.cabal b/swarm.cabal index caef9b22d..ba9d40f38 100644 --- a/swarm.cabal +++ b/swarm.cabal @@ -1020,15 +1020,16 @@ library swarm-tui Swarm.TUI.Model Swarm.TUI.Model.Achievements Swarm.TUI.Model.DebugOption + Swarm.TUI.Model.Dialog + Swarm.TUI.Model.Dialog.Goal + Swarm.TUI.Model.Dialog.Popup + Swarm.TUI.Model.Dialog.Structure Swarm.TUI.Model.Event - Swarm.TUI.Model.Goal Swarm.TUI.Model.KeyBindings Swarm.TUI.Model.Menu Swarm.TUI.Model.Name - Swarm.TUI.Model.Popup Swarm.TUI.Model.Repl Swarm.TUI.Model.StateUpdate - Swarm.TUI.Model.Structure Swarm.TUI.Model.UI Swarm.TUI.Model.WebCommand Swarm.TUI.Panel @@ -1041,6 +1042,7 @@ library swarm-tui Swarm.TUI.View.Logo Swarm.TUI.View.Objective Swarm.TUI.View.Popup + Swarm.TUI.View.Robot Swarm.TUI.View.Structure Swarm.TUI.View.Util diff --git a/test/bench/Benchmark.hs b/test/bench/Benchmark.hs index e0cbabae3..9e139d83b 100644 --- a/test/bench/Benchmark.hs +++ b/test/bench/Benchmark.hs @@ -142,7 +142,9 @@ mkGameState prog robotMaker numRobots = do -- NOTE: This replaces "classicGame0", which is still used by unit tests. gs <- simpleErrorHandle $ do - (_ :: Seq SystemFailure, initRS) <- runAccum mempty $ initRuntimeState $ RuntimeOptions False False + (_ :: Seq SystemFailure, initRS) <- + runAccum mempty . initRuntimeState $ + RuntimeOptions {startPaused = False, pauseOnObjectiveCompletion = False, loadTestScenarios = False} (scenario, _) <- loadStandaloneScenario "classic" return $ pureScenarioToGameState scenario 0 0 Nothing $ view stdGameConfigInputs initRS