diff --git a/src/Swarm/Game/Recipe.hs b/src/Swarm/Game/Recipe.hs index 16e90fa34..0e580a651 100644 --- a/src/Swarm/Game/Recipe.hs +++ b/src/Swarm/Game/Recipe.hs @@ -33,6 +33,7 @@ module Swarm.Game.Recipe ( inRecipeMap, -- * Looking up recipes + knowsIngredientsFor, recipesFor, make, make', @@ -186,6 +187,13 @@ missingIngredientsFor (inv, ins) (Recipe inps _ reqs _ _) = findLacking inven = filter ((> 0) . fst) . map (countNeeded inven) countNeeded inven (need, entity) = (need - E.lookup entity inven, entity) +-- | Figure out if a recipe is available, but it can be lacking items. +knowsIngredientsFor :: (Inventory, Inventory) -> Recipe Entity -> Bool +knowsIngredientsFor (inv, ins) recipe = + knowsAll inv (recipe ^. recipeInputs) && knowsAll ins (recipe ^. recipeRequirements) + where + knowsAll xs = all (E.contains xs . snd) + -- | Try to make a recipe, deleting the recipe's inputs from the -- inventory. Return either a description of which items are -- lacking, if the inventory does not contain sufficient inputs, diff --git a/src/Swarm/Game/State.hs b/src/Swarm/Game/State.hs index 40f6b3cfa..f835247fe 100644 --- a/src/Swarm/Game/State.hs +++ b/src/Swarm/Game/State.hs @@ -36,6 +36,9 @@ module Swarm.Game.State ( robotMap, robotsByLocation, activeRobots, + availableRecipes, + availableRecipesNewCount, + allDiscoveredEntities, gensym, randGen, adjList, @@ -197,6 +200,9 @@ data GameState = GameState -- append to a list than to a Set. _waitingRobots :: Map Integer [RID] , _robotsByLocation :: Map (V2 Int64) IntSet + , _allDiscoveredEntities :: Inventory + , _availableRecipes :: [Recipe Entity] + , _availableRecipesNewCount :: Int , _gensym :: Int , _randGen :: StdGen , _adjList :: Array Int Text @@ -263,6 +269,15 @@ robotMap :: Lens' GameState (IntMap Robot) -- happen. robotsByLocation :: Lens' GameState (Map (V2 Int64) IntSet) +-- | The list of entities that have been discovered. +allDiscoveredEntities :: Lens' GameState Inventory + +-- | The list of available recipes. +availableRecipes :: Lens' GameState [Recipe Entity] + +-- | The number of new recipes (reset to 0 when the player open the recipes view). +availableRecipesNewCount :: Lens' GameState Int + -- | The names of the robots that are currently not sleeping. activeRobots :: Getter GameState IntSet activeRobots = internalActiveRobots @@ -512,6 +527,9 @@ initGameState = do , _runStatus = Running , _robotMap = IM.empty , _robotsByLocation = M.empty + , _availableRecipes = mempty + , _availableRecipesNewCount = 0 + , _allDiscoveredEntities = empty , _activeRobots = IS.empty , _waitingRobots = M.empty , _gensym = 0 diff --git a/src/Swarm/Game/Step.hs b/src/Swarm/Game/Step.hs index a45590ec8..35c8db7c5 100644 --- a/src/Swarm/Game/Step.hs +++ b/src/Swarm/Game/Step.hs @@ -25,6 +25,7 @@ import Control.Monad (forM_, guard, msum, unless, when) import Data.Array (bounds, (!)) import Data.Bool (bool) import Data.Either (rights) +import Data.Foldable (traverse_) import qualified Data.Functor.Const as F import Data.Int (Int64) import qualified Data.IntMap as IM @@ -752,6 +753,7 @@ execConst c vs s k = do Just n -> fromMaybe e <$> uses entityMap (lookupEntityName n) robotInventory %= insert e' + updateDiscoveredEntities e' -- Return the name of the item obtained. return $ Out (VString (e' ^. entityName)) s k @@ -891,6 +893,7 @@ execConst c vs s k = do -- take recipe inputs from inventory and add outputs after recipeTime robotInventory .= invTaken + traverse_ (updateDiscoveredEntities . snd) (recipe ^. recipeOutputs) finishCookingRecipe recipe (WorldUpdate Right) (RobotUpdate changeInv) _ -> badConst Has -> case vs of @@ -964,6 +967,7 @@ execConst c vs s k = do Nothing -> return $ VInj False VUnit Just e -> do robotInventory %= insertCount 0 e + updateDiscoveredEntities e return $ VInj True (VString (e ^. entityName)) return $ Out res s k @@ -1085,6 +1089,8 @@ execConst c vs s k = do `isJustOrFail` ["I've never heard of", indefiniteQ name <> "."] robotInventory %= insert e + updateDiscoveredEntities e + return $ Out VUnit s k _ -> badConst Ishere -> case vs of @@ -1627,3 +1633,33 @@ safeExp :: Has (Throw Exn) sig m => Integer -> Integer -> m Integer safeExp a b | b < 0 = throwError $ CmdFailed Exp "Negative exponent" | otherwise = return $ a ^ b + +-- | Update the global list of discovered entities, and check for new recipes. +updateDiscoveredEntities :: (Has (State GameState) sig m, Has (State Robot) sig m) => Entity -> m () +updateDiscoveredEntities e = do + allDiscovered <- use allDiscoveredEntities + if E.contains0plus e allDiscovered + then pure () + else do + let newAllDiscovered = E.insertCount 1 e allDiscovered + updateAvailableRecipes (newAllDiscovered, newAllDiscovered) e + allDiscoveredEntities .= newAllDiscovered + +-- | Update the availableRecipes list. +-- This implementation is not efficient: +-- * Every time we discover a new entity, we iterate through the entire list of recipes to see which ones we can make. +-- Trying to do something more clever seems like it would definitely be a case of premature optimization. +-- One doesn't discover new entities all that often. +-- * For each usable recipe, we do a linear search through the list of known recipes to see if we already know it. +-- This is a little more troubling, since it's quadratic in the number of recipes. +-- But it probably doesn't really make that much difference until we get up to thousands of recipes. +updateAvailableRecipes :: Has (State GameState) sig m => (Inventory, Inventory) -> Entity -> m () +updateAvailableRecipes invs e = do + allInRecipes <- use recipesIn + let entityRecipes = recipesFor allInRecipes e + usableRecipes = filter (knowsIngredientsFor invs) entityRecipes + knownRecipes <- use availableRecipes + let newRecipes = filter (`notElem` knownRecipes) usableRecipes + newCount = length newRecipes + availableRecipes .= newRecipes <> knownRecipes + availableRecipesNewCount += newCount diff --git a/src/Swarm/TUI/Attr.hs b/src/Swarm/TUI/Attr.hs index 97cadaf9d..360a9e935 100644 --- a/src/Swarm/TUI/Attr.hs +++ b/src/Swarm/TUI/Attr.hs @@ -47,6 +47,7 @@ swarmAttrMap = , (sandAttr, fg (V.rgbColor @Int 194 178 128)) , (fireAttr, fg V.red `V.withStyle` V.bold) , (redAttr, fg V.red) + , (notifAttr, fg V.yellow `V.withStyle` V.bold) , (greenAttr, fg V.green) , (blueAttr, fg V.blue) , (deviceAttr, fg V.yellow `V.withStyle` V.bold) @@ -85,6 +86,7 @@ robotAttr , baseAttr , fireAttr , redAttr + , notifAttr , greenAttr , blueAttr , woodAttr @@ -119,13 +121,14 @@ snowAttr = "snow" sandAttr = "sand" fireAttr = "fire" redAttr = "red" +highlightAttr = "highlight" greenAttr = "green" blueAttr = "blue" rockAttr = "rock" woodAttr = "wood" baseAttr = "base" deviceAttr = "device" -highlightAttr = "highlight" +notifAttr = "notif" sepAttr = "sep" infoAttr = "info" defAttr = "def" diff --git a/src/Swarm/TUI/Controller.hs b/src/Swarm/TUI/Controller.hs index e94e27749..64d5835af 100644 --- a/src/Swarm/TUI/Controller.hs +++ b/src/Swarm/TUI/Controller.hs @@ -191,6 +191,9 @@ handleMainEvent s = \case | isJust (s ^. uiState . uiError) -> continue $ s & uiState . uiError .~ Nothing | isJust (s ^. uiState . uiModal) -> maybeUnpause s >>= (continue . (uiState . uiModal .~ Nothing)) FKey 1 -> toggleModal s HelpModal >>= continue + FKey 2 | not (null (s ^. gameState . availableRecipes)) -> do + s' <- toggleModal s RecipesModal + continue (s' & gameState . availableRecipesNewCount .~ 0) ControlKey 'g' -> case s ^. uiState . uiGoal of NoGoal -> continueWithoutRedraw s UnreadGoal g -> toggleModal s (GoalModal g) >>= continue @@ -237,7 +240,7 @@ handleMainEvent s = \case Just REPLPanel -> handleREPLEvent s ev Just WorldPanel -> handleWorldEvent s ev Just RobotPanel -> handleRobotPanelEvent s ev - Just InfoPanel -> handleInfoPanelEvent s ev + Just InfoPanel -> handleInfoPanelEvent s infoScroll ev _ -> continueWithoutRedraw s mouseLocToWorldCoords :: GameState -> Brick.Location -> EventM Name (Maybe W.Coords) @@ -289,7 +292,9 @@ handleModalEvent s = \case _ -> continue s' ev -> do s' <- s & uiState . uiModal . _Just . modalDialog %%~ handleDialogEvent ev - continue s' + case s ^? uiState . uiModal . _Just . modalType of + Just RecipesModal -> handleInfoPanelEvent s' recipesScroll (VtyEvent ev) + _ -> continue s' -- | 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. @@ -792,14 +797,14 @@ descriptionModal s e = ------------------------------------------------------------ -- | Handle user events in the info panel (just scrolling). -handleInfoPanelEvent :: AppState -> BrickEvent Name AppEvent -> EventM Name (Next AppState) -handleInfoPanelEvent s = \case - Key V.KDown -> vScrollBy infoScroll 1 >> continue s - Key V.KUp -> vScrollBy infoScroll (-1) >> continue s - CharKey 'k' -> vScrollBy infoScroll 1 >> continue s - CharKey 'j' -> vScrollBy infoScroll (-1) >> continue s - Key V.KPageDown -> vScrollPage infoScroll Brick.Down >> continue s - Key V.KPageUp -> vScrollPage infoScroll Brick.Up >> continue s - Key V.KHome -> vScrollToBeginning infoScroll >> continue s - Key V.KEnd -> vScrollToEnd infoScroll >> continue s +handleInfoPanelEvent :: AppState -> ViewportScroll Name -> BrickEvent Name AppEvent -> EventM Name (Next AppState) +handleInfoPanelEvent s vs = \case + Key V.KDown -> vScrollBy vs 1 >> continue s + Key V.KUp -> vScrollBy vs (-1) >> continue s + CharKey 'k' -> vScrollBy vs 1 >> continue s + CharKey 'j' -> vScrollBy vs (-1) >> continue s + Key V.KPageDown -> vScrollPage vs Brick.Down >> continue s + Key V.KPageUp -> vScrollPage vs Brick.Up >> continue s + Key V.KHome -> vScrollToBeginning vs >> continue s + Key V.KEnd -> vScrollToEnd vs >> continue s _ -> continueWithoutRedraw s diff --git a/src/Swarm/TUI/Model.hs b/src/Swarm/TUI/Model.hs index a880e3254..c6642c86b 100644 --- a/src/Swarm/TUI/Model.hs +++ b/src/Swarm/TUI/Model.hs @@ -114,6 +114,7 @@ module Swarm.TUI.Model ( -- ** Updating populateInventoryList, infoScroll, + recipesScroll, -- * App state AppState, @@ -205,11 +206,16 @@ data Name ScenarioList | -- | The scrollable viewport for the info panel. InfoViewport + | -- | The scrollable viewport for the recipe list. + RecipesViewport deriving (Eq, Ord, Show, Read) infoScroll :: ViewportScroll Name infoScroll = viewportScroll InfoViewport +recipesScroll :: ViewportScroll Name +recipesScroll = viewportScroll RecipesViewport + ------------------------------------------------------------ -- REPL History ------------------------------------------------------------ @@ -390,6 +396,7 @@ mkReplForm r = newForm [(replPromptAsWidget r <+>) @@= editTextField promptTextL data ModalType = HelpModal + | RecipesModal | WinModal | QuitModal | DescriptionModal Entity diff --git a/src/Swarm/TUI/View.hs b/src/Swarm/TUI/View.hs index 1356100d0..b39084ee9 100644 --- a/src/Swarm/TUI/View.hs +++ b/src/Swarm/TUI/View.hs @@ -298,9 +298,16 @@ generateModal s mt = Modal mt (dialog (Just title) buttons (maxModalWindowWidth haltingMessage = case s ^. uiState . uiPrevMenu of NoMenu -> Just "Quit" _ -> Nothing + descriptionWidth = 100 (title, widget, buttons, requiredWidth) = case mt of HelpModal -> (" Help ", helpWidget, Nothing, maxModalWindowWidth) + RecipesModal -> + ( "Available Recipes" + , helpRecipes (s ^. gameState . availableRecipesNewCount) (s ^. gameState . availableRecipes) + , Nothing + , descriptionWidth + ) WinModal -> let winMsg = "Congratulations!" continueMsg = "Keep playing" @@ -310,7 +317,7 @@ generateModal s mt = Modal mt (dialog (Just title) buttons (maxModalWindowWidth , Just (0, [(stopMsg, Confirm), (continueMsg, Cancel)]) , length continueMsg + length stopMsg + 32 ) - DescriptionModal e -> (descriptionTitle e, descriptionWidget s e, Nothing, 100) + DescriptionModal e -> (descriptionTitle e, descriptionWidget s e, Nothing, descriptionWidth) QuitModal -> let quitMsg = "Are you sure you want to quit this game and return to the menu?" stopMsg = fromMaybe "Quit to menu" haltingMessage @@ -333,6 +340,7 @@ helpWidget = (helpKeys <=> fill ' ') <+> (helpCommands <=> fill ' ') toWidgets (k, v) = [txt k, txt v] glKeyBindings = [ ("F1", "Help") + , ("F2", "Available recipes") , ("Ctrl-q", "quit the game") , ("Tab", "cycle panel focus") , ("Meta-w", "focus on the world map") @@ -355,6 +363,19 @@ helpWidget = (helpKeys <=> fill ' ') <+> (helpCommands <=> fill ' ') , ("has \"\"", "Check for an item in the inventory") ] +helpRecipes :: Int -> [Recipe Entity] -> Widget Name +helpRecipes count xs = viewport RecipesViewport Vertical (padTop (Pad 1) $ vBox recipesLists) + where + (news, knowns) = splitAt count xs + recipesLists = drawRecipes news <> sepRecipes <> drawRecipes knowns + drawRecipes = map (padLeftRight 18 . padBottom (Pad 1) . drawRecipe Nothing Nothing) + -- TODO: figure out how to make the whole hBorder to be red, not just the label + sepRecipes + | count > 0 && not (null knowns) = + [ padBottom (Pad 1) (withAttr redAttr $ hBorderWithLabel (padLeftRight 1 (txt "new↑"))) + ] + | otherwise = [] + descriptionTitle :: Entity -> String descriptionTitle e = " " ++ from @Text (e ^. entityName) ++ " " @@ -372,6 +393,7 @@ drawKeyMenu s = . (++ [gameModeWidget]) . map (padLeftRight 1 . drawKeyCmd) . (globalKeyCmds ++) + . map (\(k, n) -> (NoHighlight, k, n)) . keyCmdsFor . focusGetCurrent . view (uiState . uiFocusRing) @@ -384,6 +406,14 @@ drawKeyMenu s = cheat = s ^. uiState . uiCheatMode goal = (s ^. uiState . uiGoal) /= NoGoal + availRecipes + | null (s ^. gameState . availableRecipes) = [] + | otherwise = + let highlight + | s ^. gameState . availableRecipesNewCount > 0 = Highlighted + | otherwise = NoHighlight + in [(highlight, "F2", "Recipes")] + gameModeWidget = padLeft Max . padLeftRight 1 . txt @@ -392,11 +422,12 @@ drawKeyMenu s = False -> "Classic" True -> "Creative" globalKeyCmds = - [ ("F1", "help") - , ("Tab", "cycle") - ] - ++ [("^k", "creative") | cheat] - ++ [("^g", "goal") | goal] + [(NoHighlight, "F1", "help")] + <> availRecipes + <> [(NoHighlight, "Tab", "cycle")] + <> [(NoHighlight, "^k", "creative") | cheat] + <> [(NoHighlight, "^g", "goal") | goal] + keyCmdsFor (Just REPLPanel) = [ ("↓↑", "history") ] @@ -421,9 +452,12 @@ drawKeyMenu s = ] keyCmdsFor _ = [] +data KeyHighlight = NoHighlight | Highlighted + -- | Draw a single key command in the menu. -drawKeyCmd :: (Text, Text) -> Widget Name -drawKeyCmd (key, cmd) = txt $ T.concat ["[", key, "] ", cmd] +drawKeyCmd :: (KeyHighlight, Text, Text) -> Widget Name +drawKeyCmd (Highlighted, key, cmd) = hBox [withAttr notifAttr (txt $ T.concat ["[", key, "] "]), txt cmd] +drawKeyCmd (NoHighlight, key, cmd) = txt $ T.concat ["[", key, "] ", cmd] ------------------------------------------------------------ -- World panel @@ -587,7 +621,7 @@ explainRecipes s e , padLeftRight 2 $ hCenter $ vBox $ - map (hLimit widthLimit . padBottom (Pad 1) . drawRecipe e inv) recipes + map (hLimit widthLimit . padBottom (Pad 1) . drawRecipe (Just e) (Just inv)) recipes ] where recipes = recipesWith s e @@ -612,8 +646,8 @@ recipesWith s e = -- | Draw an ASCII art representation of a recipe. For now, the -- weight is not shown. -drawRecipe :: Entity -> Inventory -> Recipe Entity -> Widget Name -drawRecipe e inv (Recipe ins outs reqs time _weight) = +drawRecipe :: Maybe Entity -> Maybe Inventory -> Recipe Entity -> Widget Name +drawRecipe me minv (Recipe ins outs reqs time _weight) = vBox -- any requirements (e.g. furnace) go on top. [ hCenter $ drawReqs reqs @@ -655,7 +689,9 @@ drawRecipe e inv (Recipe ins outs reqs time _weight) = ) ] where - missing = E.lookup ingr inv < n + missing = case minv of + Just inv -> E.lookup ingr inv < n + Nothing -> False drawOut i (n, ingr) = hBox @@ -673,7 +709,7 @@ drawRecipe e inv (Recipe ins outs reqs time _weight) = -- If it's the focused entity, draw it highlighted. -- If the robot doesn't have any, draw it in red. fmtEntityName missing ingr - | ingr == e = withAttr deviceAttr $ txtLines nm + | Just ingr == me = withAttr deviceAttr $ txtLines nm | ingr == timeE = withAttr sandAttr $ txtLines nm | missing = withAttr invalidFormInputAttr $ txtLines nm | otherwise = txtLines nm